From 19a75a2359815a297239aa42ba5b748d2e11266e Mon Sep 17 00:00:00 2001 From: Bit-Code-Developer Date: Sun, 16 Feb 2025 17:38:56 +0600 Subject: [PATCH 001/102] feat: add unique on multiple column --- src/Blueprint.php | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/Blueprint.php b/src/Blueprint.php index 6ac9bf8..8b07ef3 100644 --- a/src/Blueprint.php +++ b/src/Blueprint.php @@ -434,9 +434,13 @@ public function index($type = null) return $this; } - public function unique() + public function unique($column = null) { - $this->uniqueIndex[] = $this->columns[$this->columnIndex]['name']; + if (\is_null($column)) { + $this->uniqueIndex[] = $this->columns[$this->columnIndex]['name']; + } else { + $this->uniqueIndex[] = $column; + } return $this; } @@ -753,7 +757,11 @@ private function addUniqueIndexQuery() $query = ''; foreach ($this->uniqueIndex as $key => $uniqueColumn) { - $query .= "\nUNIQUE INDEX {$uniqueColumn}_UNIQUE ({$uniqueColumn} ASC),"; + if (\is_array($uniqueColumn)) { + $query .= "\nUNIQUE INDEX " . implode('_', $uniqueColumn) . '_UNIQUE (' . implode(',', $uniqueColumn) . '),'; + } else { + $query .= "\nUNIQUE INDEX {$uniqueColumn}_UNIQUE ({$uniqueColumn} ASC),"; + } } return $query; @@ -767,14 +775,6 @@ private function addForeignKeyQuery() $query = ''; foreach ($this->foreignKeys as $fkId => $foreignKey) { - /* $query .= "\nCONSTRAINT f_c_{$this->table}_{$fkId} " - ." FOREIGN KEY f_key_{$this->table}_{$fkId} ({$foreignKey['column']})" - ." REFERENCES {$foreignKey['ref']} ({$foreignKey['ref_col']})" - . (isset($foreignKey['onUpdate']) ? " ON DELETE {$foreignKey['onUpdate']}" : null) - . (isset($foreignKey['onUpdate']) ? " ON UPDATE {$foreignKey['onUpdate']}" : null) - . (isset($foreignKey['both']) ? " ON DELETE {$foreignKey['both']} ON UPDATE {$foreignKey['both']}" : null) - . ","; */ - $query .= " FOREIGN KEY ({$foreignKey['column']}) REFERENCES {$foreignKey['ref']} "; $query .= "({$foreignKey['ref_col']})"; $query .= (isset($foreignKey['onDelete']) ? " ON DELETE {$foreignKey['onDelete']}" : null); From d513b151a1b799129665fa528a4d8487735d28ed Mon Sep 17 00:00:00 2001 From: Bit-Code-Developer Date: Sun, 16 Feb 2025 18:19:30 +0600 Subject: [PATCH 002/102] fix: remove trailing comma --- src/Blueprint.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Blueprint.php b/src/Blueprint.php index 8b07ef3..2544254 100644 --- a/src/Blueprint.php +++ b/src/Blueprint.php @@ -764,7 +764,7 @@ private function addUniqueIndexQuery() } } - return $query; + return rtrim($query, ','); } private function addForeignKeyQuery() From 4a4bd179147c98a472959c327cae0f897f3e83ef Mon Sep 17 00:00:00 2001 From: Bit-Code-Developer Date: Sun, 16 Feb 2025 19:03:59 +0600 Subject: [PATCH 003/102] feat: orderby raw query --- src/QueryBuilder.php | 41 ++++++++++++++++++++++++++++++++--------- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index 37bfbb0..6c13e27 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -466,11 +466,11 @@ public function paginate($pageNo = 0, $perPage = 10) { $selectedColumns = empty($this->select) ? ['*'] : $this->select; - $totalItems = (int) $this->count(); + $totalItems = (int) $this->count(); $offset = ($pageNo > 1) ? ($pageNo * $perPage) - $perPage : 0; - - $data = $this->take($perPage)->skip($offset)->get($selectedColumns); + + $data = $this->take($perPage)->skip($offset)->get($selectedColumns); $pages = ceil($totalItems / $perPage); @@ -666,7 +666,14 @@ public function getOrderBy() } foreach ($this->orderBy as $order) { - $sql .= $order['column'] . ' ' . $order['direction'] . ', '; + if (isset($order['raw'])) { + $sql .= $order['raw'] . ' ' . $order['direction'] . ', '; + $this->addBindings($order['bindings']); + + continue; + } elseif (isset($order['column'])) { + $sql .= $order['column'] . ' ' . $order['direction'] . ', '; + } } return ' ORDER BY ' . rtrim($sql, ', '); @@ -689,6 +696,25 @@ public function orderBy($column) return $this; } + /** + * Sets order by raw query + * + * @param string $query + * @param array $bindings + * + * @return $this + */ + public function orderByRaw($query, $bindings = []) + { + $this->orderBy[] = [ + 'raw' => $query, + 'direction' => 'ASC', + 'bindings' => $bindings, + ]; + + return $this; + } + /** * Sets ascending order * @@ -994,7 +1020,6 @@ public function prepare($sql = null) return empty($this->bindings) || strpos($sql, '%') === false ? $sql : Connection::prepare($sql, $this->bindings); - } /** @@ -1306,12 +1331,10 @@ private function bulkInsert($attributes) ', ', array_map( function ($value) { - if (\is_null($value)) { return 'NULL'; } - $this->bindings[] = $value; return $this->getValueType($value); @@ -1454,9 +1477,9 @@ private function prepareAttributeForSaveOrUpdate($isUpdate = false) $this->bindings[] = \is_array($this->_model->{$column}) || \is_object($this->_model->{$column}) ? wp_json_encode($this->_model->{$column}) : $this->_model->{$column}; - } elseif (\is_null($this->_model->{$column})) { + } elseif (\is_null($this->_model->{$column})) { $this->bindings[] = null; - }else { + } else { $this->bindings[] = ''; } } From d49944eabed6d8cd5f0c623b355154d4bfd70bc9 Mon Sep 17 00:00:00 2001 From: Bit-Code-Developer Date: Mon, 17 Feb 2025 12:00:07 +0600 Subject: [PATCH 004/102] chore: removed order direction from orderByRaw --- src/QueryBuilder.php | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index 6c13e27..5d1257e 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -667,10 +667,8 @@ public function getOrderBy() foreach ($this->orderBy as $order) { if (isset($order['raw'])) { - $sql .= $order['raw'] . ' ' . $order['direction'] . ', '; + $sql .= $order['raw'] . ', '; $this->addBindings($order['bindings']); - - continue; } elseif (isset($order['column'])) { $sql .= $order['column'] . ' ' . $order['direction'] . ', '; } @@ -707,9 +705,8 @@ public function orderBy($column) public function orderByRaw($query, $bindings = []) { $this->orderBy[] = [ - 'raw' => $query, - 'direction' => 'ASC', - 'bindings' => $bindings, + 'raw' => $query, + 'bindings' => $bindings, ]; return $this; From 92437df78758cc66e04ab4529dc1234aadb07a2e Mon Sep 17 00:00:00 2001 From: Bit-Code-Developer Date: Tue, 18 Feb 2025 14:11:23 +0600 Subject: [PATCH 005/102] fix: binding is not reseting after executing a query, causes mismatch bindings with placeholder --- src/QueryBuilder.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index 5d1257e..78634af 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -112,7 +112,7 @@ public function getModel() * * @param string $_for * - * @return void + * @return self */ public function queryFor($_for) { @@ -1602,6 +1602,8 @@ private function exec($sql = null) throw new Exception('SQL query is null'); } + $this->bindings = []; + Connection::query($sql); if (!empty(Connection::prop('last_error'))) { From 6aae61db5f4b80b121b3449ba3015b4b05bfa3c3 Mon Sep 17 00:00:00 2001 From: Bit-Code-Developer Date: Thu, 20 Feb 2025 17:20:51 +0600 Subject: [PATCH 006/102] reafctor: querybuilder - update() and save() will return the model - delete, destroy will return affected rows count --- src/QueryBuilder.php | 46 +++++++++++++++++++------------------------- src/Relations.php | 21 ++++++++++++++++++++ 2 files changed, 41 insertions(+), 26 deletions(-) diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index 78634af..3cb14c2 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -6,8 +6,14 @@ use DateTime; use DateTimeZone; +use Error; use Exception; +/** + * @mixin Model + * + * @method Model with(string $relationName, callable(QueryBuilder) $callback) + */ class QueryBuilder { public const UPDATE = 'Update'; @@ -83,6 +89,15 @@ public function __clone() $this->bindings = []; } + public function __call($name, $arguments) + { + if (method_exists($this->_model, $name)) { + return $this->_model->{$name}(...$arguments); + } + + throw new Error('Call to undefined method ' . __CLASS__ . '::' . esc_html($name) . '()'); + } + /** * Sets alias for table * @@ -835,9 +850,8 @@ public function update($attributes = []) { $this->_method = self::UPDATE; $this->_model->fill($attributes); - $this->update = $this->prepareAttributeForSaveOrUpdate(true); - return $this; + return $this->save(); } /** @@ -880,9 +894,7 @@ public function save() $this->update = $columns; - $this->exec(); - - return Connection::prop('rows_affected'); + return $this->exec() ? $this->_model : false; } $this->insert = $columns; @@ -890,7 +902,7 @@ public function save() if ($insertId = $this->lastInsertId()) { $this->_model->setAttribute($pk, $insertId); - return true; + return $this->_model; } return false; @@ -953,24 +965,6 @@ public function delete() return $this->exec(); } - /** - * Adds relation for model - * - * @param string|Closure $relation - * - * @return $this - */ - public function with($relation) - { - $args = \func_get_args(); - $relationalQuery = $this->_model->addRelation($relation); - if ($relationalQuery && \func_num_args() === 2 && $args[1] instanceof Closure) { - $args[1]($relationalQuery); - } - - return $this; - } - /** * Starts transaction * @@ -1604,13 +1598,13 @@ private function exec($sql = null) $this->bindings = []; - Connection::query($sql); + $result = Connection::query($sql); if (!empty(Connection::prop('last_error'))) { return false; } - return Connection::prop('last_result'); + return $this->_method === self::SELECT ? Connection::prop('last_result') : $result; } /** diff --git a/src/Relations.php b/src/Relations.php index 0a2d26c..368e921 100644 --- a/src/Relations.php +++ b/src/Relations.php @@ -6,6 +6,8 @@ namespace BitApps\WPDatabase; +use Closure; + if (!\defined('ABSPATH')) { exit; } @@ -122,6 +124,25 @@ public function getRelationalKeys() return $this->_relationKeys; } + /** + * Adds relation for model + * + * @param string|Closure $relation + * + * @return $this + */ + public function with($relation) + { + error_log(print_r(['relation' => $relation], true)); + $args = \func_get_args(); + $relationalQuery = $this->addRelation($relation); + if ($relationalQuery && \func_num_args() === 2 && $args[1] instanceof Closure) { + $args[1]($relationalQuery); + } + + return $this; + } + private function getRelationKeys($foreignKey, $localKey) { if (!$foreignKey) { From 1693881e697932b08f09a7561aac1d9a60905549 Mon Sep 17 00:00:00 2001 From: Bit-Code-Developer Date: Thu, 27 Feb 2025 18:21:19 +0600 Subject: [PATCH 007/102] wip: with aggregate function --- src/Model.php | 1 - src/QueryBuilder.php | 93 ++++++++++++++++++++++++++------ src/Relations.php | 125 ++++++++++++++++++++++++++++++++++++++----- 3 files changed, 188 insertions(+), 31 deletions(-) diff --git a/src/Model.php b/src/Model.php index 97aec23..afd2d04 100644 --- a/src/Model.php +++ b/src/Model.php @@ -65,7 +65,6 @@ * @method static QueryBuilder withCount() * @method static null|int count() * @method static bool|string delete() - * @method static QueryBuilder with($relation) * @method static void startTransaction() * @method static void commit() * @method static void rollback() diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index 3cb14c2..88e83f2 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -26,6 +26,8 @@ class QueryBuilder public const TIME_FORMAT = 'Y-m-d H:i:s'; + public $select = []; + protected $table; protected $limit; @@ -48,8 +50,6 @@ class QueryBuilder protected $bindings = []; - protected $select = []; - protected $insert = []; protected $update = []; @@ -188,6 +188,20 @@ public function all($columns = ['*']) return $this->get($columns); } + public function prepareColumnName(string $column) + { + if (strpos($column, '.') !== false) { + return $column; + } + + $table = "`{$this->table}`."; + if ($column != '*') { + $column = "`{$column}`"; + } + + return $table . $column; + } + /** * Selects column for query * @@ -197,7 +211,49 @@ public function all($columns = ['*']) */ public function select($columns = ['*']) { - $this->select = !\is_array($columns) ? \func_get_args() : $columns; + $select = \is_array($columns) ? $columns : \func_get_args(); + + $this->select = []; + foreach ($select as $column) { + $this->select[] = $this->prepareColumnName($column); + } + + return $this; + } + + /** + * Adds column to select list + * + * @param array|string $columns + * + * @return $this + */ + public function addSelect($columns) + { + $select = !\is_array($columns) ? \func_get_args() : $columns; + + foreach ($select as $column) { + if (\in_array($column, $this->select, true)) { + continue; + } + $this->select[] = $this->prepareColumnName($column); + } + + return $this; + } + + /** + * Selects raw query as column for query + * + * @param array|string $columns + * + * @return $this + */ + public function selectRaw($columns) + { + $select = !\is_array($columns) ? \func_get_args() : $columns; + + $this->select = array_merge($this->select, $select); return $this; } @@ -211,9 +267,9 @@ public function select($columns = ['*']) */ public function get($columns = ['*']) { - $columns = isset($columns) && \is_array($columns) ? $columns : \func_get_args(); + $columns = \is_array($columns) ? $columns : \func_get_args(); if (empty($this->select) || $columns !== ['*']) { - $this->select = $columns; + $this->select($columns); } $this->_method = self::SELECT; @@ -908,17 +964,17 @@ public function save() return false; } - /** - * Set count for select - * - * @return $this - */ - public function withCount() - { - $this->select[] = 'COUNT(*) as count'; + // /** + // * Set count for select + // * + // * @return $this + // */ + // public function withCount() + // { + // $this->select[] = 'COUNT(*) as count'; - return $this; - } + // return $this; + // } /** * Get counts for current model @@ -1004,8 +1060,11 @@ public function rollback() */ public function prepare($sql = null) { + error_log(print_r([$this->_method], true)); if (\is_null($sql) && isset($this->_method)) { $sql = $this->{'prepare' . $this->_method}(); + } elseif (!empty($this->select)) { + $sql = $this->prepareSelect(); } return empty($this->bindings) @@ -1570,7 +1629,9 @@ private function prepareUpdate() private function prepareDelete() { if (property_exists($this->_model, 'soft_deletes') && $this->_model->soft_deletes) { - return $this->update(['deleted_at' => $this->currentTimestamp()])->prepareUpdate(); + $this->update['deleted_at'] = $this->currentTimestamp(); + + return $this->prepareUpdate(); } $sql = 'DELETE FROM ' . $this->table; diff --git a/src/Relations.php b/src/Relations.php index 368e921..d2780be 100644 --- a/src/Relations.php +++ b/src/Relations.php @@ -44,6 +44,7 @@ public function belongsTo($model, $foreignKey = null, $localKey = null) public function newBelongsTo($model, $foreignKey = null, $localKey = null) { $model = new $model(); + $model->setParent($this); $model->setRelateAs('oneToOne'); $model->_relationKeys['oneToOne'] = [ 'foreignKey' => $foreignKey, @@ -70,6 +71,7 @@ public function hasMany($model, $foreignKey = null, $localKey = null) public function newHasMany($model, $foreignKey = null, $localKey = null) { $model = new $model(); + $model->setParent($this); $model->setRelateAs('hasMany'); $model->_relationKeys['hasMany'] = [ 'foreignKey' => $foreignKey, @@ -91,6 +93,7 @@ public function belongsToMany($model, $foreignKey = null, $localKey = null) public function newBelongsToMany($model, $foreignKey = null, $localKey = null) { $model = new $model(); + $model->setParent($this); $model->setRelateAs('belongsToMany'); $model->_relationKeys['belongsToMany'] = [ 'foreignKey' => $foreignKey, @@ -110,13 +113,9 @@ public function getRelations() return $this->_relations; } - public function addRelation($relation) + public function addRelation(array $relation) { - if (method_exists($this, $relation)) { - $this->_relations[$relation] = $this->{$relation}(); - - return $this->_relations[$relation]; - } + $this->_relations = array_merge($this->_relations, $relation); } public function getRelationalKeys() @@ -124,25 +123,121 @@ public function getRelationalKeys() return $this->_relationKeys; } + /** + * Prepares relations to query + * + * @param array $relations + * + * @return array + */ + public function prepareRelation(array $relations): array + { + $preparedRelation = []; + foreach ($relations as $key => $value) { + if (\is_int($key) && \is_string($value) && ($method = explode(' ', $value)[0]) && method_exists($this, $method)) { + $preparedRelation[$value] = $this->{$method}(); + unset($relations[$key]); + } + } + + foreach ($relations as $key => $value) { + if (\is_string($key) && ($method = explode(' ', $key)[0]) && method_exists($this, $method)) { + $preparedRelation[$key] = $this->{$method}(); + if ($value instanceof Closure) { + $value($preparedRelation[$key]); + } + } + } + + return $preparedRelation; + } + /** * Adds relation for model * - * @param string|Closure $relation + * @param string|array $relation + * @param Closure $callback + * + * @return $this + */ + public function with($relation, $callback = null) + { + $relations = []; + if ($callback instanceof Closure) { + $relations = $this->prepareRelation([$relation => $callback]); + } else { + $relations = $this->prepareRelation( + \is_string($relation) ? [$relation => null] : \func_get_args() + ); + } + + $this->addRelation($relations); + + return $this; + } + + /** + * Adds count as sub query in this query + * + * @param string + * @param mixed $relation * * @return $this */ - public function with($relation) + public function withCount($relation) { - error_log(print_r(['relation' => $relation], true)); - $args = \func_get_args(); - $relationalQuery = $this->addRelation($relation); - if ($relationalQuery && \func_num_args() === 2 && $args[1] instanceof Closure) { - $args[1]($relationalQuery); + return $this->withAggregate(\is_array($relation) ? $relation : \func_get_args() , '*', 'count'); + } + + /** + * Adds aggregate sub query to this query + * + * @param array $relation + * @param mixed $column + * @param mixed $function + * + * @return $this + */ + public function withAggregate($relation, $column, $function) + { + if (empty($relation)) { + return $this; + } + + if (empty($this->getQueryBuilder()->select)) { + $this->getQueryBuilder()->select = ["`{$this->getTable()}`.*"]; + } + + foreach ($this->prepareRelation(\is_array($relation) ? $relation : [$relation]) as $relationName => $relationalQuery) { + [$name, $alias] = $this->prepareRelationName($relationName); + if (\is_null($alias)) { + $alias = strtolower($name . '_' . $function); + } + + $relationKey = $relationalQuery->getModel() + ->getRelationalKeys()[$relationalQuery->getModel()->getRelateAs()]; + $query = $relationalQuery->whereRaw($relationalQuery->prepareColumnName($relationKey['foreignKey']) . '=' . $this->getQueryBuilder()->prepareColumnName($relationKey['localKey']))->selectRaw('count(*)')->prepare(); + $this->getQueryBuilder()->selectRaw("({$query}) as `{$alias}`"); + + error_log(print_r([$relationKey, $alias, $query], true)); } return $this; } + public function prepareRelationName(string $relationName): array + { + $name = $relationName; + $alias = null; + $nameChunk = explode(' ', $relationName); + + if (\count($nameChunk) === 3 && strtolower($nameChunk[1]) === 'as') { + $alias = $nameChunk[2]; + } + + return [$name, $alias]; + } + private function getRelationKeys($foreignKey, $localKey) { if (!$foreignKey) { @@ -201,7 +296,9 @@ private function setRelatedData(Model $model) $data = $data[0]; } - $model->setAttribute($relationName, $data); + [$name, $alias] = $this->prepareRelationName($relationName); + + $model->setAttribute(\is_null($alias) ? $name : $alias, $data); } } } From 43fb931359fabca7d357d25431b00f869532b6a2 Mon Sep 17 00:00:00 2001 From: anisurov Date: Fri, 28 Feb 2025 22:58:20 +0600 Subject: [PATCH 008/102] feat: bool cast, withCast --- src/Model.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/Model.php b/src/Model.php index afd2d04..17e31f6 100644 --- a/src/Model.php +++ b/src/Model.php @@ -434,6 +434,17 @@ private static function getInstance() return self::$_instance; } + public function withCast(array $casts) + { + if (!isset($this->casts)) { + $this->casts = []; + } + + $this->casts = array_merge($this->casts, $casts); + + return $this; + } + private function castTo($column, $value) { if (\is_null($value)) { @@ -479,6 +490,11 @@ private function castToString($value) return (string) $value; } + private function castToBool($value) + { + return \boolval($value); + } + private function castToDate($value) { return DateTime::createFromFormat('Y-m-d H:i:s', $value); From ae071adb328477612025b497f93ac6410d2aee35 Mon Sep 17 00:00:00 2001 From: anisurov Date: Fri, 28 Feb 2025 22:59:42 +0600 Subject: [PATCH 009/102] fix: update function --- src/QueryBuilder.php | 39 +++++++++++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index 88e83f2..4f08d46 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -904,10 +904,14 @@ public function insert($attributes = []) */ public function update($attributes = []) { - $this->_method = self::UPDATE; $this->_model->fill($attributes); + if ($this->_model->exists()) { + return $this->save(); + } + + $this->update = $this->prepareAttributeForSaveOrUpdate(true); - return $this->save(); + return $this->exec(); } /** @@ -1060,11 +1064,8 @@ public function rollback() */ public function prepare($sql = null) { - error_log(print_r([$this->_method], true)); - if (\is_null($sql) && isset($this->_method)) { - $sql = $this->{'prepare' . $this->_method}(); - } elseif (!empty($this->select)) { - $sql = $this->prepareSelect(); + if (\is_null($sql)) { + $sql = $this->toSql(); } return empty($this->bindings) @@ -1072,6 +1073,22 @@ public function prepare($sql = null) ? $sql : Connection::prepare($sql, $this->bindings); } + /** + * Prepares current query string + * + * @return string + */ + public function toSql() + { + if (isset($this->_method)) { + $sql = $this->{'prepare' . $this->_method}(); + } elseif (!empty($this->select)) { + $sql = $this->prepareSelect(); + } + + return $sql; + } + /** * Process conditions * @@ -1338,7 +1355,7 @@ protected function currentTimestamp() $absHour = abs($hours); $absMins = abs($minutes * 60); - $timezoneString = sprintf('%s%02d:%02d', $sign, $absHour, $absMins); + $timezoneString = \sprintf('%s%02d:%02d', $sign, $absHour, $absMins); } $dateTime = new DateTime('now', new DateTimeZone($timezoneString)); @@ -1504,9 +1521,12 @@ private function getLimit() */ private function prepareAttributeForSaveOrUpdate($isUpdate = false) { - if ($isUpdate) { + if ($isUpdate && $this->_model->exists()) { $columnsToPrepare = array_keys($this->_model->getDirtyAttributes()); $this->bindings = []; + } elseif ($isUpdate && !$this->_model->exists()) { + $columnsToPrepare = array_keys($this->_model->getAttributes()); + $this->bindings = []; } else { $columnsToPrepare = array_keys($this->_model->getAttributes()); } @@ -1652,7 +1672,6 @@ private function exec($sql = null) if (\is_null($sql)) { $sql = $this->prepare($sql); } - if (\is_null($sql)) { throw new Exception('SQL query is null'); } From 300b963357780e351e52a436131681b0a5f542be Mon Sep 17 00:00:00 2001 From: anisurov Date: Fri, 28 Feb 2025 23:02:11 +0600 Subject: [PATCH 010/102] feat: relational subselect for aggregate --- src/Relations.php | 66 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 60 insertions(+), 6 deletions(-) diff --git a/src/Relations.php b/src/Relations.php index d2780be..ff5834c 100644 --- a/src/Relations.php +++ b/src/Relations.php @@ -44,7 +44,6 @@ public function belongsTo($model, $foreignKey = null, $localKey = null) public function newBelongsTo($model, $foreignKey = null, $localKey = null) { $model = new $model(); - $model->setParent($this); $model->setRelateAs('oneToOne'); $model->_relationKeys['oneToOne'] = [ 'foreignKey' => $foreignKey, @@ -71,7 +70,6 @@ public function hasMany($model, $foreignKey = null, $localKey = null) public function newHasMany($model, $foreignKey = null, $localKey = null) { $model = new $model(); - $model->setParent($this); $model->setRelateAs('hasMany'); $model->_relationKeys['hasMany'] = [ 'foreignKey' => $foreignKey, @@ -93,7 +91,6 @@ public function belongsToMany($model, $foreignKey = null, $localKey = null) public function newBelongsToMany($model, $foreignKey = null, $localKey = null) { $model = new $model(); - $model->setParent($this); $model->setRelateAs('belongsToMany'); $model->_relationKeys['belongsToMany'] = [ 'foreignKey' => $foreignKey, @@ -208,6 +205,9 @@ public function withAggregate($relation, $column, $function) $this->getQueryBuilder()->select = ["`{$this->getTable()}`.*"]; } + if ($column !== '*') { + $column = $this->getQueryBuilder()->prepareColumnName($column); + } foreach ($this->prepareRelation(\is_array($relation) ? $relation : [$relation]) as $relationName => $relationalQuery) { [$name, $alias] = $this->prepareRelationName($relationName); if (\is_null($alias)) { @@ -216,15 +216,69 @@ public function withAggregate($relation, $column, $function) $relationKey = $relationalQuery->getModel() ->getRelationalKeys()[$relationalQuery->getModel()->getRelateAs()]; - $query = $relationalQuery->whereRaw($relationalQuery->prepareColumnName($relationKey['foreignKey']) . '=' . $this->getQueryBuilder()->prepareColumnName($relationKey['localKey']))->selectRaw('count(*)')->prepare(); - $this->getQueryBuilder()->selectRaw("({$query}) as `{$alias}`"); + $relationalQuery->whereRaw($this->getQueryBuilder()->prepareColumnName($relationKey['localKey']) . '=' . $relationalQuery->prepareColumnName($relationKey['foreignKey'])); + + if ($function === 'exists') { + $query = $relationalQuery->select($column)->prepare(); + $this->getQueryBuilder()->selectRaw("exists({$query}) as `{$alias}`")->withCast([$alias => 'bool']); + } else { + $query = $relationalQuery->selectRaw(\sprintf('%s(%s)', $function, $column))->prepare(); + $this->getQueryBuilder()->selectRaw("({$query}) as `{$alias}`"); + } + } + + return $this; + } + + public function whereHas($relation, $callback = null) + { + $relations = []; + if ($callback instanceof Closure) { + $relations = $this->prepareRelation([$relation => $callback]); + } else { + $relations = $this->prepareRelation( + \is_string($relation) ? [$relation => null] : \func_get_args() + ); + } + + foreach ($relations as $relationName => $relationalQuery) { + $relationKey = $relationalQuery->getModel() + ->getRelationalKeys()[$relationalQuery->getModel()->getRelateAs()]; + $relationalQuery->whereRaw($this->getQueryBuilder()->prepareColumnName($relationKey['localKey']) . '=' . $relationalQuery->prepareColumnName($relationKey['foreignKey'])); - error_log(print_r([$relationKey, $alias, $query], true)); + $query = $relationalQuery->select('*')->prepare(); + $this->getQueryBuilder()->whereRaw("exists({$query})"); } return $this; } + public function withWhereHas($relation, $callback = null) + { + $relations = []; + if ($callback instanceof Closure) { + $relations = $this->prepareRelation([$relation => $callback]); + } else { + $relations = $this->prepareRelation( + \is_string($relation) ? [$relation => null] : \func_get_args() + ); + } + + foreach ($relations as $relationName => $relationalQuery) { + $relationKey = $relationalQuery->getModel() + ->getRelationalKeys()[$relationalQuery->getModel()->getRelateAs()]; + $newQuery = $relationalQuery->newQuery(); + $newQuery->whereRaw($this->getQueryBuilder()->prepareColumnName($relationKey['localKey']) . '=' . $newQuery->prepareColumnName($relationKey['foreignKey'])); + + $query = $newQuery->select('*')->prepare(); + $this->getQueryBuilder()->whereRaw("exists({$query})"); + } + + $this->addRelation($relations); + + return $this; + } + public function prepareRelationName(string $relationName): array { $name = $relationName; From 07930df28a7602a23679cca6ec571593d5e0bb4f Mon Sep 17 00:00:00 2001 From: Bit-Code-Developer Date: Mon, 3 Mar 2025 14:49:03 +0600 Subject: [PATCH 011/102] feat: model event --- src/HasEvents.php | 69 ++++++++++++++++++++++++++++++++++++++++++++ src/Model.php | 58 ++++++++++++++++++++++++++++++------- src/QueryBuilder.php | 59 ++++++++++++++++++++++++++++++++++++- src/Relations.php | 2 +- 4 files changed, 176 insertions(+), 12 deletions(-) create mode 100644 src/HasEvents.php diff --git a/src/HasEvents.php b/src/HasEvents.php new file mode 100644 index 0000000..4f585c0 --- /dev/null +++ b/src/HasEvents.php @@ -0,0 +1,69 @@ +primaryKey = 'id'; } + $this->bootIfNotBooted(); + if (\is_array($attributes)) { $this->fill($attributes); } else { @@ -360,6 +364,7 @@ public function getInstanceFromBuilder($result, $setAttribute = false) $this->retrieveRelateData($this->getQueryBuilder()); if (\count($result) == 1 && $setAttribute) { + $this->fireEvent('retrieved'); $this->fill((array) $result[0], true); $this->setExists(true); $this->setRelatedData($this); @@ -374,6 +379,7 @@ function ($row) { $model->fill((array) $row, true); $this->setRelatedData($model); $model->setExists(true); + $this->fireEvent('retrieved', $model); return $model; }, @@ -425,15 +431,6 @@ public function jsonSerialize() return $this->attributes; } - private static function getInstance() - { - if (\is_null(self::$_instance)) { - self::$_instance = new static(); - } - - return self::$_instance; - } - public function withCast(array $casts) { if (!isset($this->casts)) { @@ -445,6 +442,47 @@ public function withCast(array $casts) return $this; } + /** + * Check if the model needs to be booted and if so, do it. + * + * @return void + */ + protected function bootIfNotBooted() + { + if (! isset(static::$booted[static::class])) { + static::$booted[static::class] = true; + + $this->fireEvent('booting'); + + static::booting(); + static::boot(); + static::booted(); + + $this->fireEvent('booted'); + } + } + + protected static function booting() + { + } + + protected static function boot() + { + } + + protected static function booted() + { + } + + private static function getInstance() + { + if (\is_null(self::$_instance)) { + self::$_instance = new static(); + } + + return self::$_instance; + } + private function castTo($column, $value) { if (\is_null($value)) { diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index 4f08d46..bc4cba4 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -1089,6 +1089,19 @@ public function toSql() return $sql; } + public function when($value = null, ?callable $callback = null, ?callable $default = null) + { + $value = $value instanceof Closure ? $value($this) : $value; + + if ($value) { + return $callback($this, $value) ?? $this; + } elseif ($default) { + return $default($this, $value) ?? $this; + } + + return $this; + } + /** * Process conditions * @@ -1355,7 +1368,7 @@ protected function currentTimestamp() $absHour = abs($hours); $absMins = abs($minutes * 60); - $timezoneString = \sprintf('%s%02d:%02d', $sign, $absHour, $absMins); + $timezoneString = sprintf('%s%02d:%02d', $sign, $absHour, $absMins); } $dateTime = new DateTime('now', new DateTimeZone($timezoneString)); @@ -1669,6 +1682,7 @@ private function prepareDelete() */ private function exec($sql = null) { + $this->dispatchEvent('pre'); if (\is_null($sql)) { $sql = $this->prepare($sql); } @@ -1683,10 +1697,53 @@ private function exec($sql = null) if (!empty(Connection::prop('last_error'))) { return false; } + $this->dispatchEvent('post'); return $this->_method === self::SELECT ? Connection::prop('last_result') : $result; } + /** + * Dispatches model event + * + * @param string $type pre|post + */ + private function dispatchEvent($type) + { + $prefix = null; + $suffix = null; + + if ($type === 'pre') { + $suffix = 'ing'; + } else { + $suffix = 'ed'; + } + + switch ($this->_method) { + case self::INSERT: + if (\count($this->_model->getAttributes())) { + $suffix = 'creat'; + } + + break; + case self::UPDATE: + if ($this->_model->exists()) { + $suffix = 'updat'; + } + + break; + case self::DELETE: + if ($this->_model->exists()) { + $suffix = 'delet'; + } + + break; + } + + if (!\is_null($prefix)) { + $this->_model->fireEvent($prefix . $suffix); + } + } + /** * Returns last id * diff --git a/src/Relations.php b/src/Relations.php index ff5834c..d5964b0 100644 --- a/src/Relations.php +++ b/src/Relations.php @@ -222,7 +222,7 @@ public function withAggregate($relation, $column, $function) $query = $relationalQuery->select($column)->prepare(); $this->getQueryBuilder()->selectRaw("exists({$query}) as `{$alias}`")->withCast([$alias => 'bool']); } else { - $query = $relationalQuery->selectRaw(\sprintf('%s(%s)', $function, $column))->prepare(); + $query = $relationalQuery->selectRaw(sprintf('%s(%s)', $function, $column))->prepare(); $this->getQueryBuilder()->selectRaw("({$query}) as `{$alias}`"); } } From 2ee3099c5a5ef8666d4218e422c41542c5ee5f74 Mon Sep 17 00:00:00 2001 From: Bit-Code-Developer Date: Wed, 5 Mar 2025 13:08:08 +0600 Subject: [PATCH 012/102] fix: prepared query is wrong, it is prepareing through wrong method --- src/QueryBuilder.php | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index bc4cba4..b8ac3ec 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -24,6 +24,8 @@ class QueryBuilder public const SELECT = 'Select'; + public const RAW = 'Raw'; + public const TIME_FORMAT = 'Y-m-d H:i:s'; public $select = []; @@ -833,7 +835,19 @@ public function desc() */ public function raw($sql, $bindings = []) { - return $this->exec(Connection::prepare($sql, $bindings)); + $this->_method = self::RAW; + $this->raw = $sql; + $this->bindings = $bindings; + + return $this->exec(); + } + + public function prepareRaw() + { + $raw = $this->raw; + $this->raw = ''; + + return $raw; } /** From d54ba7a2dd909499f87ad89471df8096af22ed83 Mon Sep 17 00:00:00 2001 From: Bit-Code-Developer Date: Wed, 5 Mar 2025 13:43:27 +0600 Subject: [PATCH 013/102] fix: raw query result issue --- src/QueryBuilder.php | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index b8ac3ec..00f6b6c 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -839,15 +839,20 @@ public function raw($sql, $bindings = []) $this->raw = $sql; $this->bindings = $bindings; - return $this->exec(); + $result = $this->exec(); + + if (preg_match('/^SELECT /i', $sql)) { + $result = Connection::prop('last_result'); + } + + $this->raw = ''; + + return $result; } public function prepareRaw() { - $raw = $this->raw; - $this->raw = ''; - - return $raw; + return $this->raw; } /** From 43e4aebe4a538777caeb3e0f72fe09a364dabafd Mon Sep 17 00:00:00 2001 From: Bit-Code-Developer Date: Wed, 5 Mar 2025 14:05:15 +0600 Subject: [PATCH 014/102] fix: trim query to match --- src/QueryBuilder.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index 00f6b6c..c411175 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -841,11 +841,12 @@ public function raw($sql, $bindings = []) $result = $this->exec(); - if (preg_match('/^SELECT /i', $sql)) { + if (preg_match('/^SELECT /i', trim($sql))) { $result = Connection::prop('last_result'); } $this->raw = ''; + unset($this->_method); return $result; } From b550e996aa009098503d4beedca6c721ed0e0463 Mon Sep 17 00:00:00 2001 From: Bit-Code-Developer Date: Thu, 6 Mar 2025 12:35:29 +0600 Subject: [PATCH 015/102] fix: event not triggering --- src/HasEvents.php | 12 ++++++------ src/QueryBuilder.php | 6 +++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/HasEvents.php b/src/HasEvents.php index 4f585c0..c6a1ba3 100644 --- a/src/HasEvents.php +++ b/src/HasEvents.php @@ -16,12 +16,7 @@ trait HasEvents { protected static $events = []; - protected static function registerEvent($event, Closure $callback) - { - static::$events[$event] = $callback; - } - - protected function fireEvent($event, $model = null) + public function fireEvent($event, $model = null) { if (\is_null($model)) { $model = $this; @@ -32,6 +27,11 @@ protected function fireEvent($event, $model = null) } } + protected static function registerEvent($event, Closure $callback) + { + static::$events[$event] = $callback; + } + protected static function retrieved(Closure $callback) { static::registerEvent('retrieved', $callback); diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index c411175..926c536 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -1741,19 +1741,19 @@ private function dispatchEvent($type) switch ($this->_method) { case self::INSERT: if (\count($this->_model->getAttributes())) { - $suffix = 'creat'; + $prefix = 'sav'; } break; case self::UPDATE: if ($this->_model->exists()) { - $suffix = 'updat'; + $prefix = 'updat'; } break; case self::DELETE: if ($this->_model->exists()) { - $suffix = 'delet'; + $prefix = 'delet'; } break; From 27fd9b04dce6a5c8b722d13f0387fcaef9c861c1 Mon Sep 17 00:00:00 2001 From: Bit-Code-Developer Date: Thu, 6 Mar 2025 12:38:04 +0600 Subject: [PATCH 016/102] fix: prevent delete statement if where clause is null to avoid unwanted table empting --- src/QueryBuilder.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index 926c536..b2e36bb 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -1688,7 +1688,14 @@ private function prepareDelete() } $sql = 'DELETE FROM ' . $this->table; - $sql .= $this->getWhere($this); + + $whereClause = $this->getWhere($this); + + if (\is_null($whereClause)) { + return; + } + + $sql .= $whereClause; return $sql; } From 4986f02eca89dc9ca35572f3d831207832e8970b Mon Sep 17 00:00:00 2001 From: Bit-Code-Developer Date: Thu, 6 Mar 2025 13:16:38 +0600 Subject: [PATCH 017/102] fix: change condition to check empty string which is not null --- src/QueryBuilder.php | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index b2e36bb..5b6dc6b 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -6,8 +6,7 @@ use DateTime; use DateTimeZone; -use Error; -use Exception; +use RuntimeException; /** * @mixin Model @@ -97,7 +96,7 @@ public function __call($name, $arguments) return $this->_model->{$name}(...$arguments); } - throw new Error('Call to undefined method ' . __CLASS__ . '::' . esc_html($name) . '()'); + throw new RuntimeException('Call to undefined method ' . __CLASS__ . '::' . esc_html($name) . '()'); } /** @@ -1691,8 +1690,8 @@ private function prepareDelete() $whereClause = $this->getWhere($this); - if (\is_null($whereClause)) { - return; + if (empty($whereClause)) { + return ''; } $sql .= $whereClause; @@ -1713,8 +1712,8 @@ private function exec($sql = null) if (\is_null($sql)) { $sql = $this->prepare($sql); } - if (\is_null($sql)) { - throw new Exception('SQL query is null'); + if (empty($sql)) { + throw new RuntimeException('SQL query is empty'); } $this->bindings = []; From 84105024391dcb493e5cc2db521aa52fa13fc5a8 Mon Sep 17 00:00:00 2001 From: Bit-Code-Developer Date: Tue, 11 Mar 2025 11:41:18 +0600 Subject: [PATCH 018/102] fix: model save event --- src/QueryBuilder.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index 5b6dc6b..28b31d8 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -977,9 +977,11 @@ public function save() } $this->insert = $columns; + $this->_model->fireEvent('saving'); $this->exec(); if ($insertId = $this->lastInsertId()) { $this->_model->setAttribute($pk, $insertId); + $this->_model->fireEvent('saved'); return $this->_model; } @@ -1747,7 +1749,7 @@ private function dispatchEvent($type) switch ($this->_method) { case self::INSERT: if (\count($this->_model->getAttributes())) { - $prefix = 'sav'; + $prefix = 'creat'; } break; From 3092cb34f511d65e4a9345f9ef5775baeba1198e Mon Sep 17 00:00:00 2001 From: Bit-Code-Developer Date: Tue, 11 Mar 2025 11:45:48 +0600 Subject: [PATCH 019/102] feat: added some aggregate relation functions --- src/QueryBuilder.php | 12 -------- src/Relations.php | 65 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 12 deletions(-) diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index 28b31d8..0be8e3f 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -989,18 +989,6 @@ public function save() return false; } - // /** - // * Set count for select - // * - // * @return $this - // */ - // public function withCount() - // { - // $this->select[] = 'COUNT(*) as count'; - - // return $this; - // } - /** * Get counts for current model * diff --git a/src/Relations.php b/src/Relations.php index d5964b0..fe22244 100644 --- a/src/Relations.php +++ b/src/Relations.php @@ -186,6 +186,71 @@ public function withCount($relation) return $this->withAggregate(\is_array($relation) ? $relation : \func_get_args() , '*', 'count'); } + /** + * Adds min as sub query in this query + * + * @param string + * @param mixed $relation + * + * @return $this + */ + public function withMin($relation) + { + return $this->withAggregate(\is_array($relation) ? $relation : \func_get_args() , '*', 'min'); + } + + /** + * Adds max as sub query in this query + * + * @param string + * @param mixed $relation + * + * @return $this + */ + public function withMax($relation) + { + return $this->withAggregate(\is_array($relation) ? $relation : \func_get_args() , '*', 'max'); + } + + /** + * Adds avg as sub query in this query + * + * @param string + * @param mixed $relation + * + * @return $this + */ + public function withAvg($relation) + { + return $this->withAggregate(\is_array($relation) ? $relation : \func_get_args() , '*', 'avg'); + } + + /** + * Adds sum as sub query in this query + * + * @param string + * @param mixed $relation + * + * @return $this + */ + public function withSum($relation) + { + return $this->withAggregate(\is_array($relation) ? $relation : \func_get_args() , '*', 'sum'); + } + + /** + * Adds exists as sub query in this query + * + * @param string + * @param mixed $relation + * + * @return $this + */ + public function withExists($relation) + { + return $this->withAggregate(\is_array($relation) ? $relation : \func_get_args() , '*', 'exists'); + } + /** * Adds aggregate sub query to this query * From c3eb31851dff7120b45848506e23749b5ce17fb5 Mon Sep 17 00:00:00 2001 From: Bit-Code-Developer Date: Wed, 12 Mar 2025 15:25:11 +0600 Subject: [PATCH 020/102] fix: event in loop issue due static prop --- src/HasEvents.php | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/HasEvents.php b/src/HasEvents.php index c6a1ba3..6b81531 100644 --- a/src/HasEvents.php +++ b/src/HasEvents.php @@ -14,7 +14,9 @@ trait HasEvents { - protected static $events = []; + protected $events = []; + + protected static $registeredEvents = []; public function fireEvent($event, $model = null) { @@ -22,14 +24,25 @@ public function fireEvent($event, $model = null) $model = $this; } - if (isset(static::$events[$event]) && \is_callable(static::$events[$event])) { - static::$events[$event]($model); + if (isset(static::$registeredEvents[static::class . $event]) && \is_callable(static::$registeredEvents[static::class . $event])) { + static::$registeredEvents[static::class . $event]($model); + } elseif (isset($this->events[$event])) { + $this->fireCustomEvent($this->events[$event], $model); + } + } + + public function fireCustomEvent($callback, $model) + { + if ($callback instanceof Closure) { + $callback($model); + } elseif (class_exists($callback) && method_exists($callback, 'handle')) { + (new $callback($model))->handle(); } } protected static function registerEvent($event, Closure $callback) { - static::$events[$event] = $callback; + static::$registeredEvents[static::class . $event] = $callback; } protected static function retrieved(Closure $callback) From c8bff115109b8058288fc13a0861033c4c37b5a7 Mon Sep 17 00:00:00 2001 From: Bit-Code-Developer Date: Wed, 12 Mar 2025 15:26:47 +0600 Subject: [PATCH 021/102] fix: reseting select due to count query --- src/QueryBuilder.php | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index 0be8e3f..09aedeb 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -99,6 +99,14 @@ public function __call($name, $arguments) throw new RuntimeException('Call to undefined method ' . __CLASS__ . '::' . esc_html($name) . '()'); } + /** + * Clone this class with all conditions + */ + public function clone() + { + return clone $this; + } + /** * Sets alias for table * @@ -536,13 +544,15 @@ public function orWhereBetween($column, $start, $end) */ public function paginate($pageNo = 0, $perPage = 10) { - $selectedColumns = empty($this->select) ? ['*'] : $this->select; + if (empty($this->select)) { + $this->select = ['*']; + } $totalItems = (int) $this->count(); $offset = ($pageNo > 1) ? ($pageNo * $perPage) - $perPage : 0; - $data = $this->take($perPage)->skip($offset)->get($selectedColumns); + $data = $this->take($perPage)->skip($offset)->get(); $pages = ceil($totalItems / $perPage); @@ -992,16 +1002,13 @@ public function save() /** * Get counts for current model * - * @return int|null + * @return int */ public function count() { - $this->select = ['COUNT(*) as count']; - $this->_method = 'Select'; - $result = $this->exec(); - unset($this->select); + $result = $this->clone()->get('COUNT(*) as count'); - return \is_array($result) && !empty($result[0]->count) ? $result[0]->count : null; + return \is_array($result) && !empty($result[0]->count) ? $result[0]->count : 0; } public function max($column) From ac312e2b696cc04ecb557d156e51024c6678b52f Mon Sep 17 00:00:00 2001 From: Bit-Code-Developer Date: Wed, 12 Mar 2025 15:54:37 +0600 Subject: [PATCH 022/102] feat: added bindings for selectRaw --- src/QueryBuilder.php | 35 +++++++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index 09aedeb..f24f604 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -29,6 +29,11 @@ class QueryBuilder public $select = []; + public $selectRaw = [ + 'columns' => [], + 'bindings' => [], + ]; + protected $table; protected $limit; @@ -254,15 +259,17 @@ public function addSelect($columns) /** * Selects raw query as column for query * - * @param array|string $columns + * @param string $column + * @param mixed $bindings * * @return $this */ - public function selectRaw($columns) + public function selectRaw($column, $bindings = null) { - $select = !\is_array($columns) ? \func_get_args() : $columns; - - $this->select = array_merge($this->select, $select); + $this->selectRaw['columns'][] = $column; + if (!empty($bindings)) { + $this->selectRaw['bindings'][] = $bindings; + } return $this; } @@ -1392,6 +1399,20 @@ protected function currentTimestamp() return $dateTime->format(self::TIME_FORMAT); } + private function prepareRawSelect() + { + $query = ''; + if (!empty($this->selectRaw['columns'])) { + $query = implode(', ', $this->selectRaw['columns']); + } + + if (!empty($this->selectRaw['bindings'])) { + $this->bindings = array_merge($this->bindings, $this->selectRaw['bindings']); + } + + return $query; + } + /** * Run bulk insert query * @@ -1596,7 +1617,9 @@ private function prepareAttributeForSaveOrUpdate($isUpdate = false) private function prepareSelect() { $this->bindings = []; - $sql = 'SELECT ' . implode(',', $this->select) . ' FROM ' . $this->table; + $sql = 'SELECT ' . implode(',', $this->select); + $sql .= $this->prepareRawSelect(); + $sql .= ' FROM ' . $this->table; $sql .= $this->getFrom(); $sql .= $this->getJoin(); $sql .= $this->getWhere($this); From dc96df526af5d697fec20014b5301da32fed87c4 Mon Sep 17 00:00:00 2001 From: Bit-Code-Developer Date: Wed, 12 Mar 2025 16:52:59 +0600 Subject: [PATCH 023/102] fix: count query --- src/QueryBuilder.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index f24f604..8199385 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -1013,7 +1013,7 @@ public function save() */ public function count() { - $result = $this->clone()->get('COUNT(*) as count'); + $result = $this->clone()->selectRaw('COUNT(*) as count')->exec(); return \is_array($result) && !empty($result[0]->count) ? $result[0]->count : 0; } @@ -1403,7 +1403,8 @@ private function prepareRawSelect() { $query = ''; if (!empty($this->selectRaw['columns'])) { - $query = implode(', ', $this->selectRaw['columns']); + $query = \count($this->select) ? ', ' : ''; + $query .= implode(', ', $this->selectRaw['columns']); } if (!empty($this->selectRaw['bindings'])) { From 6634704b2b7bf5b876568cb5384c14775048a3df Mon Sep 17 00:00:00 2001 From: Bit-Code-Developer Date: Wed, 12 Mar 2025 17:08:43 +0600 Subject: [PATCH 024/102] fix: min, max query --- src/QueryBuilder.php | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index 8199385..9446e76 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -1020,20 +1020,14 @@ public function count() public function max($column) { - $this->select = ['MAX(' . $column . ') as max']; - $this->_method = 'Select'; - $result = $this->exec(); - unset($this->select); + $result = $this->clone()->selectRaw('MAX(`' . $column . '`) as max')->exec(); return \is_array($result) && !empty($result[0]->max) ? $result[0]->max : null; } public function min($column) { - $this->select = ['MIN(' . $column . ') as min']; - $this->_method = 'Select'; - $result = $this->exec(); - unset($this->select); + $result = $this->clone()->selectRaw('MIN(`' . $column . '`) as min')->exec(); return \is_array($result) && !empty($result[0]->min) ? $result[0]->min : null; } From ac0393076de181db16381d2e46e11981149505af Mon Sep 17 00:00:00 2001 From: Bit-Code-Developer Date: Wed, 12 Mar 2025 17:10:50 +0600 Subject: [PATCH 025/102] fix: add qoute to col name in condition --- src/QueryBuilder.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index 9446e76..22d1a38 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -1503,7 +1503,7 @@ private function getFrom() private function prepareColumnForWhere($clause) { if (isset($clause['column'])) { - return ' ' . $clause['column']; + return ' ' . $this->prepareColumnName($clause['column']); } } From 2dcdb3678ec7546b5cf65ce2e0a33e7e1ddcf2e9 Mon Sep 17 00:00:00 2001 From: Bit-Code-Developer Date: Thu, 13 Mar 2025 16:13:04 +0600 Subject: [PATCH 026/102] chore: Prevent query execution if pre-query event returns false --- src/HasEvents.php | 8 ++++---- src/QueryBuilder.php | 6 ++++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/HasEvents.php b/src/HasEvents.php index 6b81531..8c6a099 100644 --- a/src/HasEvents.php +++ b/src/HasEvents.php @@ -25,18 +25,18 @@ public function fireEvent($event, $model = null) } if (isset(static::$registeredEvents[static::class . $event]) && \is_callable(static::$registeredEvents[static::class . $event])) { - static::$registeredEvents[static::class . $event]($model); + return static::$registeredEvents[static::class . $event]($model); } elseif (isset($this->events[$event])) { - $this->fireCustomEvent($this->events[$event], $model); + return $this->fireCustomEvent($this->events[$event], $model); } } public function fireCustomEvent($callback, $model) { if ($callback instanceof Closure) { - $callback($model); + return $callback($model); } elseif (class_exists($callback) && method_exists($callback, 'handle')) { - (new $callback($model))->handle(); + return (new $callback($model))->handle(); } } diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index 22d1a38..c4fd84d 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -1723,7 +1723,9 @@ private function prepareDelete() */ private function exec($sql = null) { - $this->dispatchEvent('pre'); + if ($this->dispatchEvent('pre') === false) { + return false; + } if (\is_null($sql)) { $sql = $this->prepare($sql); } @@ -1781,7 +1783,7 @@ private function dispatchEvent($type) } if (!\is_null($prefix)) { - $this->_model->fireEvent($prefix . $suffix); + return $this->_model->fireEvent($prefix . $suffix); } } From 26404d73ab4d2b8b4cdb358cf619d87890be1a99 Mon Sep 17 00:00:00 2001 From: Bit-Code-Developer Date: Sat, 15 Mar 2025 12:27:48 +0600 Subject: [PATCH 027/102] fix: aggregate functions --- src/QueryBuilder.php | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index c4fd84d..1c4b69b 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -260,15 +260,15 @@ public function addSelect($columns) * Selects raw query as column for query * * @param string $column - * @param mixed $bindings + * @param array $bindings * * @return $this */ - public function selectRaw($column, $bindings = null) + public function selectRaw($column, array $bindings = []) { $this->selectRaw['columns'][] = $column; if (!empty($bindings)) { - $this->selectRaw['bindings'][] = $bindings; + $this->selectRaw['bindings'] = array_merge($this->selectRaw['bindings'], $bindings); } return $this; @@ -1013,23 +1013,28 @@ public function save() */ public function count() { - $result = $this->clone()->selectRaw('COUNT(*) as count')->exec(); - - return \is_array($result) && !empty($result[0]->count) ? $result[0]->count : 0; + return $this->aggregate('COUNT', $this->_model->getPrimaryKey()); } public function max($column) { - $result = $this->clone()->selectRaw('MAX(`' . $column . '`) as max')->exec(); - - return \is_array($result) && !empty($result[0]->max) ? $result[0]->max : null; + return $this->aggregate('MAX', $column); } public function min($column) { - $result = $this->clone()->selectRaw('MIN(`' . $column . '`) as min')->exec(); + return $this->aggregate('MIN', $column); + } + + public function aggregate($function, $column) + { + $query = $this->clone(); + $query->select = []; + $query->selectRaw = []; + $result = $query->selectRaw($function . '(' . $query->prepareColumnName($column) . ') as ' . $function)->exec(); + error_log(print_r(compact('result'), true)); - return \is_array($result) && !empty($result[0]->min) ? $result[0]->min : null; + return \is_array($result) && !empty($result[0]->{$function}) ? $result[0]->{$function} : null; } public function delete() @@ -1099,8 +1104,9 @@ public function toSql() { if (isset($this->_method)) { $sql = $this->{'prepare' . $this->_method}(); - } elseif (!empty($this->select)) { - $sql = $this->prepareSelect(); + } elseif (!empty($this->select) || !empty($this->selectRaw)) { + $this->_method = self::SELECT; + $sql = $this->prepareSelect(); } return $sql; From 229cfae65ad9627a0efe8f07ce517b7a2e58d888 Mon Sep 17 00:00:00 2001 From: Bit-Code-Developer Date: Sun, 16 Mar 2025 15:32:12 +0600 Subject: [PATCH 028/102] fix: mismatch binding in closure query --- src/QueryBuilder.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index 1c4b69b..99fe503 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -1152,6 +1152,7 @@ protected function processConditions($conditions, $type = null) } if (isset($clause['query']) && !\is_null($type)) { + $clause['query']->bindings = []; $sql .= ' (' . $clause['query']->getConditions($clause['query'], $type) . ')'; $this->addBindings($clause['query']->getBindings()); From d6f9d54265f2445490834ec029c9d9516aca7704 Mon Sep 17 00:00:00 2001 From: Bit-Code-Develope Date: Tue, 22 Apr 2025 14:49:04 +0600 Subject: [PATCH 029/102] fix: bulk insert is not returning all inserted model --- src/QueryBuilder.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index 99fe503..2400ebd 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -1032,7 +1032,6 @@ public function aggregate($function, $column) $query->select = []; $query->selectRaw = []; $result = $query->selectRaw($function . '(' . $query->prepareColumnName($column) . ') as ' . $function)->exec(); - error_log(print_r(compact('result'), true)); return \is_array($result) && !empty($result[0]->{$function}) ? $result[0]->{$function} : null; } @@ -1467,10 +1466,10 @@ function ($value) { if ($this->raw($sql, $this->bindings) !== false) { $nextID = $this->lastInsertId(); - $ids[] = $nextID; + $ids = []; $affectedRows = Connection::prop('rows_affected') - 1; while ($affectedRows--) { - $ids[] = $nextID + 1; + $ids[] = $nextID++; } if ( From bd7fc725a82d4a6f77226caf5e32ed56fc4a8742 Mon Sep 17 00:00:00 2001 From: Bit-Code-Develope Date: Tue, 22 Apr 2025 15:37:05 +0600 Subject: [PATCH 030/102] fix: bulk insert is not returning all inserted model due not considering row_affected --- src/QueryBuilder.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index 2400ebd..5634266 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -1467,7 +1467,7 @@ function ($value) { if ($this->raw($sql, $this->bindings) !== false) { $nextID = $this->lastInsertId(); $ids = []; - $affectedRows = Connection::prop('rows_affected') - 1; + $affectedRows = Connection::prop('rows_affected'); while ($affectedRows--) { $ids[] = $nextID++; } From 3101158227aa7c197bdf28d129367d936b657281 Mon Sep 17 00:00:00 2001 From: Bit-Code-Develope Date: Tue, 29 Apr 2025 17:49:02 +0600 Subject: [PATCH 031/102] feat: set timezone statically --- src/QueryBuilder.php | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index 5634266..ac0adb6 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -27,6 +27,8 @@ class QueryBuilder public const TIME_FORMAT = 'Y-m-d H:i:s'; + public static $TIME_ZONE; + public $select = []; public $selectRaw = [ @@ -1380,7 +1382,18 @@ protected function getValueType($value) */ protected function currentTimestamp() { - if (\function_exists('wp_timezone_string')) { + $timezoneString = $this->getTimeZone(); + + $dateTime = new DateTime('now', new DateTimeZone($timezoneString)); + + return $dateTime->format(self::TIME_FORMAT); + } + + protected function getTimeZone() + { + if (isset(static::$TIME_ZONE)) { + $timezoneString = static::$TIME_ZONE; + } elseif (\function_exists('wp_timezone_string')) { $timezoneString = wp_timezone_string(); } elseif (!($timezoneString = get_option('timezone_string'))) { $offset = (float) get_option('gmt_offset'); @@ -1394,9 +1407,7 @@ protected function currentTimestamp() $timezoneString = sprintf('%s%02d:%02d', $sign, $absHour, $absMins); } - $dateTime = new DateTime('now', new DateTimeZone($timezoneString)); - - return $dateTime->format(self::TIME_FORMAT); + return $timezoneString; } private function prepareRawSelect() @@ -1466,7 +1477,7 @@ function ($value) { if ($this->raw($sql, $this->bindings) !== false) { $nextID = $this->lastInsertId(); - $ids = []; + $ids = []; $affectedRows = Connection::prop('rows_affected'); while ($affectedRows--) { $ids[] = $nextID++; From 17ec2ab06d5fa1d6c1877b2cae8a71c5f5782ca9 Mon Sep 17 00:00:00 2001 From: Bit-Code-Develope Date: Sat, 3 May 2025 13:41:22 +0600 Subject: [PATCH 032/102] feat: upsert record on duplicate --- src/QueryBuilder.php | 64 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index ac0adb6..3b632b0 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -1500,6 +1500,70 @@ function ($value) { return false; } + public function upsert(array $values, array|null $update = null) + { + if(!is_array(reset($values))) { + $values = [$values]; + } + + if (is_null($update)) { + $update = array_keys($values[0]); + } + + $this->bindings = []; + $columns = array_keys($values[0]); + ksort($columns); + $createdAt = property_exists($this->_model, 'timestamps') && $this->_model->timestamps && !in_array('created_at', $columns); + if ($createdAt) { + $columns[] = 'created_at'; + } + $sql = 'INSERT INTO ' . $this->table; + $sql .= ' (' . implode(', ', $columns) . ')'; + + $sql .= ' VALUES '; + $insertAbleValues = []; + foreach ($values as $row) { + ksort($row); + if ($createdAt) { + $row['created_at'] = $this->currentTimestamp(); + } + + $rowValues = array_values($row); + $insertAbleValues[] = ' (' + . implode( + ', ', + array_map( + function ($value) { + if (\is_null($value)) { + return 'NULL'; + } + + $this->bindings[] = $value; + + return $this->getValueType($value); + }, + $rowValues + ) + ) . ')'; + } + + $sql .= empty($insertAbleValues) ? ' default values' : ' ' . implode(',', $insertAbleValues); + $sql .= ' ON DUPLICATE KEY UPDATE '; + if (array_key_exists('created_at', $update)) { + $update = array_diff($update, ['created_at']); + $update[] = 'updated_at'; + } + $update = array_map(function($column) { + if ($column === 'updated_at') { + return $column . ' = VALUES(created_at)'; + } + return $column . ' = VALUES(' . $column . ')'; + }, $update); + $sql .= implode(', ', $update); + $sql .= ';'; + return $this->raw($sql, $this->bindings); + } + /** * Table alias for select query * From 1f93af4163ca1a76c96592e07fb8591c70bee45f Mon Sep 17 00:00:00 2001 From: Bit-Code-Develope Date: Sat, 3 May 2025 13:41:51 +0600 Subject: [PATCH 033/102] feat: collection --- src/Collection.php | 125 +++++++++++++++++++++++++++++++++++++++++++++ src/Model.php | 10 +++- 2 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 src/Collection.php diff --git a/src/Collection.php b/src/Collection.php new file mode 100644 index 0000000..afeb346 --- /dev/null +++ b/src/Collection.php @@ -0,0 +1,125 @@ +items = $items; + } + + public static function make($items = []) + { + return new static(is_array($items) ? $items : [$items]); + } + + public function all() + { + return $this->items; + } + + public function map(callable $callback) + { + return new static(array_map($callback, $this->items)); + } + + public function filter(callable $callback) + { + return new static(array_filter($this->items, $callback, ARRAY_FILTER_USE_BOTH)); + } + + public function reduce(callable $callback, $initial = null) + { + return array_reduce($this->items, $callback, $initial); + } + + public function pluck($key) + { + return new static(array_map(function ($item) use ($key) { + return is_array($item) ? $item[$key] ?? null : (is_object($item) ? $item->{$key} ?? null : null); + }, $this->items)); + } + + public function first(callable|null $callback = null, $default = null) + { + foreach ($this->items as $item) { + if ($callback === null || $callback($item)) { + return $item; + } + } + + return $default; + } + + public function last(callable|null $callback = null, $default = null) + { + return $this->reverse()->first($callback, $default); + } + + public function reverse() + { + return new static(array_reverse($this->items)); + } + + public function toArray() + { + return array_map(function ($value) { + return is_object($value) && method_exists($value, 'toArray') ? $value->toArray() : $value; + }, $this->items); + } + + public function jsonSerialize():array + { + return $this->toArray(); + } + + #[ReturnTypeWillChange] + public function count() + { + return count($this->items); + } + + #[ReturnTypeWillChange] + public function getIterator() + { + return new ArrayIterator($this->items); + } + + #[ReturnTypeWillChange] + public function offsetExists($offset) + { + return isset($this->items[$offset]); + } + + #[ReturnTypeWillChange] + public function offsetGet($offset) + { + return $this->items[$offset] ?? null; + } + + #[ReturnTypeWillChange] + public function offsetSet($offset, $value) + { + if ($offset === null) { + $this->items[] = $value; + } else { + $this->items[$offset] = $value; + } + } + + #[ReturnTypeWillChange] + public function offsetUnset($offset) + { + unset($this->items[$offset]); + } +} \ No newline at end of file diff --git a/src/Model.php b/src/Model.php index 3d11685..9748f79 100644 --- a/src/Model.php +++ b/src/Model.php @@ -373,7 +373,7 @@ public function getInstanceFromBuilder($result, $setAttribute = false) return $this; } - return array_map( + return new Collection(array_map( function ($row) { $model = clone $this; $model->fill((array) $row, true); @@ -384,7 +384,7 @@ function ($row) { return $model; }, $result - ); + )); } public function getQueryBuilder() @@ -423,12 +423,18 @@ public function offsetUnset($offset) #[ReturnTypeWillChange] public function jsonSerialize() + { + return $this->toArray(); + } + + public function toArray() { if (!$this->exists()) { return []; } return $this->attributes; + } public function withCast(array $casts) From d747d83a4de187a47eeae47012fc2e53a2a0e380 Mon Sep 17 00:00:00 2001 From: Bit-Code-Develope Date: Wed, 7 May 2025 14:16:10 +0600 Subject: [PATCH 034/102] fix: should return empty array when result is empty but returns Collection --- src/Model.php | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/src/Model.php b/src/Model.php index 9748f79..9653ec8 100644 --- a/src/Model.php +++ b/src/Model.php @@ -362,6 +362,10 @@ public function getInstanceFromBuilder($result, $setAttribute = false) return false; } + if (\count($result) === 0) { + return []; + } + $this->retrieveRelateData($this->getQueryBuilder()); if (\count($result) == 1 && $setAttribute) { $this->fireEvent('retrieved'); @@ -373,18 +377,20 @@ public function getInstanceFromBuilder($result, $setAttribute = false) return $this; } - return new Collection(array_map( - function ($row) { - $model = clone $this; - $model->fill((array) $row, true); - $this->setRelatedData($model); - $model->setExists(true); - $this->fireEvent('retrieved', $model); - - return $model; - }, - $result - )); + return new Collection( + array_map( + function ($row) { + $model = clone $this; + $model->fill((array) $row, true); + $this->setRelatedData($model); + $model->setExists(true); + $this->fireEvent('retrieved', $model); + + return $model; + }, + $result + ) + ); } public function getQueryBuilder() @@ -427,14 +433,13 @@ public function jsonSerialize() return $this->toArray(); } - public function toArray() + public function toArray() { if (!$this->exists()) { return []; } return $this->attributes; - } public function withCast(array $casts) From 1d9622a5b06ef04ef48d871bdfb9ec95983b3d11 Mon Sep 17 00:00:00 2001 From: Bit-Code-Develope Date: Mon, 14 Jul 2025 15:50:26 +0600 Subject: [PATCH 035/102] fix: raw select not returning result - Regex doesn't machtes due to have a mew line after SELECT --- src/Blueprint.php | 7 ------- src/QueryBuilder.php | 2 +- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/src/Blueprint.php b/src/Blueprint.php index 2544254..1be96bf 100644 --- a/src/Blueprint.php +++ b/src/Blueprint.php @@ -405,13 +405,6 @@ public function zeroFill() return $this; } - public function binary() - { - $this->columns[$this->columnIndex]['props'][] = 'BINARY'; - - return $this; - } - public function primary() { $this->primaryKey[] = $this->columns[$this->columnIndex]['name']; diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index 3b632b0..538807b 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -859,7 +859,7 @@ public function raw($sql, $bindings = []) $result = $this->exec(); - if (preg_match('/^SELECT /i', trim($sql))) { + if (preg_match('/^SELECT/i', trim($sql))) { $result = Connection::prop('last_result'); } From b7c8083476a6438b2887ac60b40203763c64c897 Mon Sep 17 00:00:00 2001 From: Bit-Code-Develope Date: Thu, 14 Aug 2025 12:37:46 +0600 Subject: [PATCH 036/102] refacor: transactions method added in Connection and deprecated in QB --- src/Connection.php | 30 ++++++++++++++++++++++++++++++ src/QueryBuilder.php | 3 +++ 2 files changed, 33 insertions(+) diff --git a/src/Connection.php b/src/Connection.php index 14d5cae..b248cae 100644 --- a/src/Connection.php +++ b/src/Connection.php @@ -132,6 +132,36 @@ public static function prop($var) } } + /** + * Starts transaction + * + * @return bool + */ + public static function startTransaction() + { + return self::query('START TRANSACTION'); + } + + /** + * Commits current transaction + * + * @return bool + */ + public static function commit() + { + return self::query('COMMIT'); + } + + /** + * Rollback previously execute query + * + * @return void + */ + public static function rollback() + { + return self::query('ROLLBACK'); + } + private static function _forwadCall($instance, $method, $args) { if (self::$_isLogEnabled && $method === 'query') { diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index 538807b..f493b45 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -1050,6 +1050,7 @@ public function delete() /** * Starts transaction + * @deprecated Use Connection::startTransaction() instead * * @return bool */ @@ -1060,6 +1061,7 @@ public function startTransaction() /** * Commits current transaction + * @deprecated Use Connection::commit() instead * * @return bool */ @@ -1070,6 +1072,7 @@ public function commit() /** * Rollback previously execute query + * @deprecated Use Connection::rollback() instead * * @return void */ From 18a56609640786a3c91a24e03802a6fff291e04c Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Sun, 28 Jun 2026 17:06:58 +0600 Subject: [PATCH 037/102] refactor: make relation query methods statically accessible on Model Move eager-loading and aggregate methods (with, withCount, withMin/Max/Avg/ Sum/Exists, whereHas, withWhereHas) out of the Relations trait into a new QueriesRelationships trait on QueryBuilder. Because they are no longer declared on Model, `Model::with()` etc. now resolve through __callStatic instead of fatally erroring with "Non-static method ... cannot be called statically". Relation definitions (hasMany/belongsTo) stay on the model's Relations trait. - add Model::query() as a real, IDE-navigable builder entry point - @mixin QueryBuilder + restored @method static tags for cross-IDE support (PhpStorm navigation + VS Code/Intelephense autocomplete) - fix with(['a','b']) array form (previously dropped by func_get_args) - DRY the relation-key lookup via Model::getActiveRelationKey() - add PHPUnit suite (42 tests) with wpdb/WP stubs and fixtures - document version breaking changes in docs/breaking-changes.md Assisted-By: AI --- .gitignore | 2 + docs/breaking-changes.md | 432 +++++++++++++++++++++ phpunit.xml | 17 + src/Model.php | 132 ++++--- src/QueriesRelationships.php | 228 +++++++++++ src/QueryBuilder.php | 140 +++---- src/Relations.php | 211 +--------- tests/BuilderEntryTest.php | 41 ++ tests/EagerLoadIntegrationTest.php | 55 +++ tests/Fixtures/Post.php | 14 + tests/Fixtures/User.php | 19 + tests/RelationDefinitionRegressionTest.php | 48 +++ tests/RelationStaticAccessTest.php | 121 ++++++ tests/bootstrap.php | 119 ++++++ 14 files changed, 1259 insertions(+), 320 deletions(-) create mode 100644 docs/breaking-changes.md create mode 100644 phpunit.xml create mode 100644 src/QueriesRelationships.php create mode 100644 tests/BuilderEntryTest.php create mode 100644 tests/EagerLoadIntegrationTest.php create mode 100644 tests/Fixtures/Post.php create mode 100644 tests/Fixtures/User.php create mode 100644 tests/RelationDefinitionRegressionTest.php create mode 100644 tests/RelationStaticAccessTest.php create mode 100644 tests/bootstrap.php diff --git a/.gitignore b/.gitignore index c131794..72e389b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,6 @@ vendor/ .php-cs-fixer.cache tests.config.php .phpunit.result.cache +.phpunit.cache/ +phpunit.phar composer.lock diff --git a/docs/breaking-changes.md b/docs/breaking-changes.md new file mode 100644 index 0000000..6f15f4a --- /dev/null +++ b/docs/breaking-changes.md @@ -0,0 +1,432 @@ +# Breaking Changes — v2.0 + +This release (next tag after `1.11`) contains **backward-incompatible** changes +to the query builder, model, relations and schema blueprint. Because the public +API and runtime behavior change, it is a **major** version bump. + +- [1. Summary](#1-summary) +- [2. Breaking changes](#2-breaking-changes) +- [3. Behavioral changes](#3-behavioral-changes) +- [4. New features](#4-new-features) +- [5. Deprecations](#5-deprecations) +- [6. Migration checklist](#6-migration-checklist) + +--- + +## 1. Summary + +| # | Area | Change | Impact | +|---|------|--------|--------| +| 1 | Model | `get()` returns `Collection`, not `array` | High | +| 2 | QueryBuilder | `update()` no longer chainable, runs immediately | High | +| 3 | QueryBuilder | `save()` returns `Model\|false` (was `bool`/`int`) | High | +| 4 | QueryBuilder | `select()` back-tick quotes columns; raw exprs break | High | +| 5 | QueryBuilder | `delete()` with no `WHERE` throws (was: table wipe) | High | +| 6 | Model | NULL values no longer cast | Medium | +| 7 | QueryBuilder | `raw()`/`exec()` non-SELECT return changed | Medium | +| 8 | QueryBuilder | `with()` signature changed (old `Closure`-only form gone) | Medium | +| 9 | QueryBuilder | `withCount()` removed → relation aggregate | Medium | +| 10 | Relations | `addRelation()` signature `string` → `array`, void | Medium | +| 11 | Blueprint | `binary()` removed | Low | +| 12 | QueryBuilder | exception type/message changed in `exec()` | Low | + +--- + +## 2. Breaking changes + +### 2.1 `Model::get()` / query results return a `Collection`, not an `array` + +Multi-row reads now return `BitApps\WPDatabase\Collection` instead of a plain +PHP array. Single-row reads still return the model; empty results return `[]`. + +```php +// Before +$users = User::where('active', 1)->get(); // array +is_array($users); // true +array_map($fn, $users); // OK + +// After +$users = User::where('active', 1)->get(); // Collection +is_array($users); // false ❌ +array_map($fn, $users); // TypeError ❌ +``` + +**Why it breaks:** `is_array()` checks fail; native `array_*` functions reject +the object. + +**Migration:** `Collection` implements `ArrayAccess`, `IteratorAggregate`, +`Countable`, `JsonSerializable` — so `foreach`, `$users[0]`, `count($users)` and +`json_encode($users)` keep working. Replace native array calls with collection +methods: + +```php +$users->map($fn); // instead of array_map +$users->filter($fn); // instead of array_filter +$users->pluck('id'); // column extraction +$users->first(); // first item (or matching callback) +$users->last(); +$users->reduce($fn, $initial); +$users->all(); // underlying plain array +$users->toArray(); // array of model arrays +``` + +--- + +### 2.2 `QueryBuilder::update()` is no longer chainable and executes immediately + +```php +// Before — returned $this, deferred execution +$qb->update(['name' => 'x'])->where('id', 1)->save(); + +// After — executes now, returns result (not $this) +User::where('id', 1)->update(['name' => 'x']); // runs UPDATE immediately +``` + +If the bound model **already exists**, `update()` now delegates to `save()` +(dirty-attribute update). On a fresh model it builds the update from all +attributes and executes. + +**Why it breaks:** any chain calling a method *after* `update()` breaks — +`update()` no longer returns the builder. + +**Migration:** set conditions **before** `update()`; drop trailing +`->save()`/`->exec()`. + +--- + +### 2.3 `QueryBuilder::save()` return value changed + +```php +// Before +$result = $model->save(); +// insert -> bool(true) ; update -> int rows_affected + +// After +$result = $model->save(); +// success -> the Model instance ; failure -> bool(false) +``` + +**Why it breaks:** code relying on `true` / `rows_affected` int now receives a +Model object on success. + +**Migration:** treat the return as truthy for success; read affected rows via +`Connection::prop('rows_affected')` if needed. + +--- + +### 2.4 `select()` back-tick qualifies columns — raw expressions break + +`select()` and `addSelect()` pass every column through `prepareColumnName()`, +which wraps the name as `` `table`.`column` `` unless it already contains a `.`. + +```php +// Before +->select('COUNT(*) as total') // emitted: COUNT(*) as total + +// After +->select('COUNT(*) as total') // emitted: `COUNT(*) as total` ❌ invalid SQL +``` + +**Why it breaks:** any raw SQL expression, function call, or alias passed to +`select()` is now quoted as a single identifier. + +**Migration:** use `selectRaw()` for expressions; plain columns need no change: + +```php +->selectRaw('COUNT(*) as total') +->selectRaw('SUM(amount) as amt', $bindings) +``` + +--- + +### 2.5 `delete()` with no `WHERE` clause no longer wipes the table + +A delete producing no `WHERE` clause now returns empty SQL, and `exec()` throws +instead of running `DELETE FROM table` (which previously emptied the table). + +```php +// Before +User::delete(); // ⚠️ deleted every row + +// After +User::delete(); // throws RuntimeException('SQL query is empty') +``` + +**Why it breaks (intentionally):** prevents accidental full-table deletes. + +**Migration:** add an explicit condition — `User::where('id', $id)->delete()`. To +truly empty a table, use a raw `TRUNCATE`/`DELETE` query. + +--- + +### 2.6 NULL values are no longer cast + +`castTo()` short-circuits and returns `null` when the value is `null`, instead of +running it through the configured cast. + +```php +// casts = ['count' => 'int', 'flag' => 'bool'] + +// Before +$m->count; // null -> (int) null => 0 +$m->flag; // null -> false + +// After +$m->count; // null (unchanged) +$m->flag; // null (unchanged) +``` + +**Why it breaks:** nullable cast columns now surface `null` rather than the +zero-value (`0`, `false`, `''`, epoch `DateTime`). + +**Migration:** null-check or coalesce — `$m->count ?? 0`. + +--- + +### 2.7 `raw()` / `exec()` return value for non-SELECT queries + +`raw()` returns the last result set **only** for `SELECT` queries. For +non-SELECT statements (INSERT/UPDATE/DELETE/DDL) it returns the underlying +`Connection::query()` result instead of `last_result`. + +**Migration:** for write statements, check truthiness / `Connection::prop()` +rather than expecting a result set. + +--- + +### 2.8 `QueryBuilder::with()` signature changed + +`with()` stays on `QueryBuilder` but gains a richer signature and is also +reachable statically/instance via the model (see §4.4). + +```php +// Before +public function with($relation) // string | Closure + +// After +public function with($relation, $callback = null) // string | array, optional Closure +``` + +Eager-loading works in every call style: + +```php +User::with('posts')->get(); +User::with('posts', fn ($q) => $q->where('published', 1))->get(); +User::with(['posts', 'profile'])->get(); +``` + +**Why it breaks:** the old single-arg `Closure` form (`with(fn ($q) => …)`) is +gone — pass the relation name first, the constraint second. + +--- + +### 2.9 `QueryBuilder::withCount()` removed — now a relation aggregate + +The old no-arg `withCount()` that appended `COUNT(*) as count` to the select is +gone. `withCount()` now lives in `Relations`, **requires a relation name**, and +emits a correlated sub-select. + +```php +// Before +->withCount()->get(); // COUNT(*) as count + +// After +User::withCount('posts')->get(); // adds posts_count sub-select +``` + +**Migration:** for a plain row count use `->count()`; for relation counts use +`withCount($relation)`. See §4.2 for the full aggregate family. + +--- + +### 2.10 `addRelation()` signature changed + +```php +// Before +public function addRelation($relation) // string; resolved method, returned query + +// After +public function addRelation(array $relation) // merges relation array, returns void +``` + +**Why it breaks:** callers passing a string, or using the return value, break. + +**Migration:** use `with()` / the new relation methods instead of calling +`addRelation()` directly. + +--- + +### 2.11 `Blueprint::binary()` removed + +The `binary()` column modifier was deleted. + +```php +$table->string('hash')->binary(); // ❌ Call to undefined method +``` + +**Migration:** express the binary/collation requirement another way (e.g. a raw +column type) until a replacement is provided. + +--- + +### 2.12 Exception type and message changed in `exec()` + +A null/empty prepared query now throws `RuntimeException('SQL query is empty')` +instead of `Exception('SQL query is null')`. + +`RuntimeException` extends `Exception`, so `catch (\Exception $e)` still works. +Code matching on the **message** string, or catching the base `Exception` type +specifically for this case, should be reviewed. + +--- + +## 3. Behavioral changes + +Not signature breaks, but observable runtime differences. + +- **`count()` now counts the primary key** (`COUNT(pk)`) via the new + `aggregate()` helper, instead of `COUNT(*)`. Rows with a NULL primary key are + no longer counted. +- **`min()` / `max()` run on a clone** of the builder, so calling them no longer + mutates the current query's `select`/`selectRaw` state. +- **Bulk insert id collection fixed** — inserted ids are now derived from + `rows_affected` starting at `lastInsertId()` (previously off by one / partial). + Bulk inserts now return all created models. +- **NULL columns persist as SQL `NULL`** in insert/update/upsert, instead of + being coerced to an empty string `''`. +- **`paginate()`** defaults `select` to `*` when empty and computes the count + before applying limit/offset; pagination with explicit `select` columns and + count no longer conflict. +- **`Model` now fires lifecycle events** during read/write — see §4.3. A model + with custom logic in overridden write paths may observe new event callbacks. + +--- + +## 4. New features + +### 4.1 `Collection` + +Returned from multi-row reads. Methods: `make()`, `all()`, `map()`, `filter()`, +`reduce()`, `pluck()`, `first()`, `last()`, `reverse()`, `toArray()`, plus +`Countable` / `ArrayAccess` / `IteratorAggregate` / `JsonSerializable`. + +### 4.2 Relation aggregates & existence + +Added to the `Relations` trait (all usable statically via the model): + +```php +User::withCount('posts')->get(); // posts_count +User::withMin('posts.score')->get(); +User::withMax('posts.score')->get(); +User::withAvg('posts.score')->get(); +User::withSum('posts.score')->get(); +User::withExists('posts')->get(); // bool-cast posts_exists +User::whereHas('posts')->get(); // filter by existence +User::whereHas('posts', fn ($q) => $q->where('published', 1))->get(); +User::withWhereHas('posts', $cb)->get(); // filter + eager-load +``` + +Relation names support an `as` alias: `withCount('posts as total_posts')`. + +### 4.3 Model events (`HasEvents` trait) + +Lifecycle hooks: `booting`, `booted`, `retrieved`, `saving`, `saved`, +`updating`, `updated`, `deleting`, `deleted`. Register in a model's `boot()` +via the protected static registrars, e.g.: + +```php +protected static function boot() +{ + static::saving(fn ($model) => /* ... */); + static::deleted(MyDeletedHandler::class); // class with handle() +} +``` + +A pre-event (`saving`/`updating`/`deleting`) returning `false` aborts the query. + +> ⚠️ The trait **reserves** the method names `boot`, `booting`, `booted`, +> `fireEvent`, `fireCustomEvent`, `registerEvent` and the properties `$events`, +> `$registeredEvents`, `$booted` on subclasses. A child model already defining +> any of these will collide. + +### 4.4 Relation eager-loading lives on `QueryBuilder` (callable statically) + +`with()`, `withCount()`, `withMin/Max/Avg/Sum/Exists()`, `whereHas()`, +`withWhereHas()` are defined on `QueryBuilder` and forwarded from the model via +`__call`/`__callStatic`. All three call styles work and produce identical SQL: + +```php +User::with('posts')->get(); // static +User::where('id', '>', 0)->with('posts')->get(); // chained on the builder +(new User())->with('posts')->get(); // instance +``` + +> **Why on the builder, not the model:** PHP only routes through `__callStatic` +> for methods that are *not* declared on the class. While these methods lived on +> the model's `Relations` trait, `Model::with(...)` threw +> `Error: Non-static method ...::with() cannot be called statically`. Moving +> them to `QueryBuilder` (where `where()`/`select()` already live) restores +> static access. Relation *definitions* (`hasMany`, `belongsTo`, …) remain on +> the model. + +**Return type:** these now return a `QueryBuilder` (previously the trait +versions returned the `Model`). Both are chainable to `->get()`, and forward +unknown methods to each other, so existing chains keep working. + +**IDE navigation:** `Model` carries `@mixin \BitApps\WPDatabase\QueryBuilder` +(instead of a hand-maintained `@method static` list), so Ctrl/Cmd+Click on a +forwarded call jumps to the real `QueryBuilder` method. For a fully +type-navigable entry that works identically across IDEs, start the chain with +the new real static `Model::query()`: + +```php +User::query()->with('posts')->where('active', 1)->get(); +``` + +### 4.5 Other additions + +- **QueryBuilder:** `addSelect()`, `selectRaw()`, `orderByRaw()`, `upsert()`, + `when()`, `toSql()`, `clone()`, `aggregate()`, `prepareColumnName()`, and + `__call()` forwarding to the bound model. +- **Model:** `query()` (canonical static builder entry), `toArray()`, + `getPrefix()`, `withCast(array $casts)`, `bool` cast. +- **Connection:** `startTransaction()`, `commit()`, `rollback()`. +- **Blueprint:** `unique($column = null)` — optional arg (backward compatible) + for composite/explicit unique indexes. +- **QueryBuilder:** `static $TIME_ZONE` to set the timezone statically; `$select` + / `$selectRaw` are now `public` (were `protected`). + +--- + +## 5. Deprecations + +| Deprecated | Replacement | +|------------|-------------| +| `QueryBuilder::startTransaction()` | `Connection::startTransaction()` | +| `QueryBuilder::commit()` | `Connection::commit()` | +| `QueryBuilder::rollback()` | `Connection::rollback()` | + +Still functional, but migrate — they may be removed in a future release. + +--- + +## 6. Migration checklist + +- [ ] Replace `is_array()` / `array_*` on query results with `Collection` + methods (§2.1). +- [ ] Remove method calls chained **after** `update()`; set `where()` before it + (§2.2). +- [ ] Update code reading `save()`'s return as `bool`/int; it returns the model + now (§2.3). +- [ ] Move raw expressions out of `select()` into `selectRaw()` (§2.4). +- [ ] Ensure every `delete()` has a `WHERE`; replace intentional table wipes + with raw SQL (§2.5). +- [ ] Null-check / coalesce values from nullable cast columns (§2.6). +- [ ] Review non-SELECT `raw()` return handling (§2.7). +- [ ] Replace the old single-arg `with(Closure)` form and the old `withCount()` + no-arg call (§2.8, §2.9). +- [ ] Replace direct `addRelation(string)` calls with `with()` (§2.10). +- [ ] Remove `binary()` column modifiers (§2.11). +- [ ] Review `catch`/message matching around the SQL-empty exception (§2.12). +- [ ] Check subclasses for name collisions with the `HasEvents` trait (§4.3). +- [ ] Migrate transaction calls from `QueryBuilder` to `Connection` (§5). diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..3f12279 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,17 @@ + + + + + tests + + + + + src + + + diff --git a/src/Model.php b/src/Model.php index 9653ec8..489199b 100644 --- a/src/Model.php +++ b/src/Model.php @@ -17,58 +17,76 @@ /** * Abstract class for model. * - * @method static QueryBuilder from($_from) - * @method static Model getModel() - * @method static QueryBuilder queryFor($_for) - * @method static QueryBuilder newQuery() - * @method static void addBindings($bindings) - * @method static array getBindings() - * @method static Model|array|false all($columns = ['*']) - * @method static QueryBuilder select($columns = ['*']) - * @method static Model|array|false get($columns = ['*']) - * @method static Model|bool first() - * @method static Model|array|false find($attributes) - * @method static Model|bool findOne($attributes) - * @method static string|string[]|null getConditions(QueryBuilder $query, $type = 'where') - * @method static string prepareOperatorForWhere($clause) - * @method static QueryBuilder where(...$params) - * @method static QueryBuilder whereIn(...$params) - * @method static QueryBuilder orWhere(...$params) - * @method static QueryBuilder whereRaw($sql, $bindings = []) - * @method static QueryBuilder whereNull($column) - * @method static QueryBuilder whereNotNull($column) - * @method static QueryBuilder whereBetween($column, $start, $end) - * @method static QueryBuilder orWhereBetween($column, $start, $end) - * @method static QueryBuilder paginate($perPage = 10, $pageNo = 0) - * @method static QueryBuilder groupBy($columns) - * @method static QueryBuilder having(...$params) - * @method static QueryBuilder orHaving(...$params) - * @method static QueryBuilder join($table, $first_column, $operator = null, $second_column = null, $type = 'INNER') - * @method static QueryBuilder leftJoin($table, $first_column, $operator = null, $second_column = null) - * @method static QueryBuilder rightJoin($table, $first_column, $operator = null, $second_column = null) - * @method static QueryBuilder fullJoin($table, $first_column, $operator = null, $second_column = null) - * @method static QueryBuilder crossJoin($table, $first_column, $operator = null, $second_column = null) - * @method static QueryBuilder on($first_column, $operator = null, $second_column = null, $bool = 'AND') - * @method static QueryBuilder orOn($first_column, $operator = null, $second_column = null) - * @method static string getOrderBy() - * @method static QueryBuilder orderBy($column) - * @method static QueryBuilder asc() - * @method static QueryBuilder desc() - * @method static object|null raw($sql, $bindings = []) - * @method static QueryBuilder take($count) - * @method static QueryBuilder skip($count) - * @method static QueryBuilder getOffset() - * @method static Model|array|false insert($attributes = []) - * @method static QueryBuilder update($attributes = []) - * @method static string|bool destroy($ids = []) - * @method static bool save() - * @method static QueryBuilder withCount() - * @method static null|int count() - * @method static bool|string delete() - * @method static void startTransaction() - * @method static void commit() - * @method static void rollback() - * @method static string prepare($sql = null) + * Query-builder calls (where/select/get/with/withCount/...) are forwarded to + * {@see QueryBuilder} through __call/__callStatic. + * + * IDE support: the @method tags below give autocomplete in every editor + * (PhpStorm + VS Code/Intelephense); the @mixin lets PhpStorm Ctrl+Click jump + * to the real definitions. For autocomplete AND Ctrl+Click that work + * everywhere, start chains with the real static {@see Model::query()}. + * + * @method static QueryBuilder from($_from) + * @method static Model getModel() + * @method static QueryBuilder queryFor($_for) + * @method static QueryBuilder newQuery() + * @method static void addBindings($bindings) + * @method static array getBindings() + * @method static Model|array|false all($columns = ['*']) + * @method static QueryBuilder select($columns = ['*']) + * @method static QueryBuilder addSelect($columns) + * @method static QueryBuilder selectRaw($column, array $bindings = []) + * @method static Model|array|false get($columns = ['*']) + * @method static Model|bool first() + * @method static Model|array|false find($attributes) + * @method static Model|bool findOne($attributes) + * @method static QueryBuilder where(...$params) + * @method static QueryBuilder whereIn(...$params) + * @method static QueryBuilder orWhere(...$params) + * @method static QueryBuilder whereRaw($sql, $bindings = []) + * @method static QueryBuilder orWhereRaw($sql, $bindings = []) + * @method static QueryBuilder whereNull($column) + * @method static QueryBuilder whereNotNull($column) + * @method static QueryBuilder whereBetween($column, $start, $end) + * @method static QueryBuilder orWhereBetween($column, $start, $end) + * @method static QueryBuilder when($value = null, ?callable $callback = null, ?callable $default = null) + * @method static QueryBuilder paginate($perPage = 10, $pageNo = 0) + * @method static QueryBuilder groupBy($columns) + * @method static QueryBuilder having(...$params) + * @method static QueryBuilder orHaving(...$params) + * @method static QueryBuilder join($table, $first_column, $operator = null, $second_column = null, $type = 'INNER') + * @method static QueryBuilder leftJoin($table, $first_column, $operator = null, $second_column = null) + * @method static QueryBuilder rightJoin($table, $first_column, $operator = null, $second_column = null) + * @method static QueryBuilder fullJoin($table, $first_column, $operator = null, $second_column = null) + * @method static QueryBuilder crossJoin($table, $first_column, $operator = null, $second_column = null) + * @method static QueryBuilder orderBy($column) + * @method static QueryBuilder orderByRaw($query, $bindings = []) + * @method static QueryBuilder asc() + * @method static QueryBuilder desc() + * @method static object|null raw($sql, $bindings = []) + * @method static QueryBuilder take($count) + * @method static QueryBuilder skip($count) + * @method static Model|array|false insert($attributes = []) + * @method static Model|bool update($attributes = []) + * @method static string|bool destroy($ids = []) + * @method static Model|bool save() + * @method static Model|bool upsert(array $values, ?array $update = null) + * @method static QueryBuilder with(string|array $relation, ?Closure $callback = null) + * @method static QueryBuilder withCount(string|array $relation) + * @method static QueryBuilder withMin(string|array $relation) + * @method static QueryBuilder withMax(string|array $relation) + * @method static QueryBuilder withAvg(string|array $relation) + * @method static QueryBuilder withSum(string|array $relation) + * @method static QueryBuilder withExists(string|array $relation) + * @method static QueryBuilder whereHas(string|array $relation, ?Closure $callback = null) + * @method static QueryBuilder withWhereHas(string|array $relation, ?Closure $callback = null) + * @method static int|null count() + * @method static mixed max($column) + * @method static mixed min($column) + * @method static bool|string delete() + * @method static string toSql() + * @method static string prepare($sql = null) + * + * @mixin \BitApps\WPDatabase\QueryBuilder */ abstract class Model implements ArrayAccess, JsonSerializable { @@ -403,6 +421,16 @@ public function newQuery() return new QueryBuilder($this); } + /** + * Canonical, IDE-navigable entry point to the query builder. + * + * @return QueryBuilder + */ + public static function query() + { + return (new static())->newQuery(); + } + #[ReturnTypeWillChange] public function offsetExists($offset) { diff --git a/src/QueriesRelationships.php b/src/QueriesRelationships.php new file mode 100644 index 0000000..c94c7c2 --- /dev/null +++ b/src/QueriesRelationships.php @@ -0,0 +1,228 @@ +_model->addRelation($this->resolveRelations($relation, $callback)); + + return $this; + } + + /** + * Adds a relation count sub query to this query. + * + * @param string|array $relation + * + * @return $this + */ + public function withCount($relation) + { + return $this->withAggregate($relation, '*', 'count'); + } + + /** + * Adds a relation min sub query to this query. + * + * @param string|array $relation + * + * @return $this + */ + public function withMin($relation) + { + return $this->withAggregate($relation, '*', 'min'); + } + + /** + * Adds a relation max sub query to this query. + * + * @param string|array $relation + * + * @return $this + */ + public function withMax($relation) + { + return $this->withAggregate($relation, '*', 'max'); + } + + /** + * Adds a relation avg sub query to this query. + * + * @param string|array $relation + * + * @return $this + */ + public function withAvg($relation) + { + return $this->withAggregate($relation, '*', 'avg'); + } + + /** + * Adds a relation sum sub query to this query. + * + * @param string|array $relation + * + * @return $this + */ + public function withSum($relation) + { + return $this->withAggregate($relation, '*', 'sum'); + } + + /** + * Adds a relation exists sub query to this query. + * + * @param string|array $relation + * + * @return $this + */ + public function withExists($relation) + { + return $this->withAggregate($relation, '*', 'exists'); + } + + /** + * Adds an aggregate sub query for the given relation(s) to this query. + * + * @param string|array $relation + * @param mixed $column + * @param string $function + * + * @return $this + */ + public function withAggregate($relation, $column, $function) + { + if (empty($relation)) { + return $this; + } + + if (empty($this->select)) { + $this->select = ["`{$this->_model->getTable()}`.*"]; + } + + if ($column !== '*') { + $column = $this->prepareColumnName($column); + } + + foreach ($this->_model->prepareRelation((array) $relation) as $relationName => $relationalQuery) { + [$name, $alias] = $this->_model->prepareRelationName($relationName); + if (\is_null($alias)) { + $alias = strtolower($name . '_' . $function); + } + + $this->correlate($relationalQuery); + + if ($function === 'exists') { + $query = $relationalQuery->select($column)->prepare(); + $this->selectRaw("exists({$query}) as `{$alias}`")->withCast([$alias => 'bool']); + } else { + $query = $relationalQuery->selectRaw(sprintf('%s(%s)', $function, $column))->prepare(); + $this->selectRaw("({$query}) as `{$alias}`"); + } + } + + return $this; + } + + /** + * Filters the query to models having the given relation. + * + * @param string|array $relation + * @param null|Closure $callback + * + * @return $this + */ + public function whereHas($relation, $callback = null) + { + foreach ($this->resolveRelations($relation, $callback) as $relationalQuery) { + $this->correlate($relationalQuery); + $this->whereRaw('exists(' . $relationalQuery->select('*')->prepare() . ')'); + } + + return $this; + } + + /** + * Filters by relation existence and eager loads the same relation. + * + * @param string|array $relation + * @param null|Closure $callback + * + * @return $this + */ + public function withWhereHas($relation, $callback = null) + { + $relations = $this->resolveRelations($relation, $callback); + + foreach ($relations as $relationalQuery) { + $existsQuery = $this->correlate($relationalQuery->newQuery()); + $this->whereRaw('exists(' . $existsQuery->select('*')->prepare() . ')'); + } + + $this->_model->addRelation($relations); + + return $this; + } + + /** + * Resolves relation names (and an optional constraint) into prepared + * relational queries. + * + * @param string|array $relation + * @param null|Closure $callback + * + * @return array + */ + private function resolveRelations($relation, $callback) + { + if (\is_string($relation)) { + return $this->_model->prepareRelation([$relation => $callback]); + } + + return $this->_model->prepareRelation((array) $relation); + } + + /** + * Adds the parent/child key correlation predicate to a relational query. + * + * @return QueryBuilder + */ + private function correlate(QueryBuilder $relationalQuery) + { + $relationKey = $relationalQuery->getModel()->getActiveRelationKey(); + + $relationalQuery->whereRaw( + $this->prepareColumnName($relationKey['localKey']) + . '=' . $relationalQuery->prepareColumnName($relationKey['foreignKey']) + ); + + return $relationalQuery; + } +} diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index f493b45..73cf549 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -8,13 +8,10 @@ use DateTimeZone; use RuntimeException; -/** - * @mixin Model - * - * @method Model with(string $relationName, callable(QueryBuilder) $callback) - */ class QueryBuilder { + use QueriesRelationships; + public const UPDATE = 'Update'; public const INSERT = 'Insert'; @@ -1050,6 +1047,7 @@ public function delete() /** * Starts transaction + * * @deprecated Use Connection::startTransaction() instead * * @return bool @@ -1061,6 +1059,7 @@ public function startTransaction() /** * Commits current transaction + * * @deprecated Use Connection::commit() instead * * @return bool @@ -1072,6 +1071,7 @@ public function commit() /** * Rollback previously execute query + * * @deprecated Use Connection::rollback() instead * * @return void @@ -1129,6 +1129,72 @@ public function when($value = null, ?callable $callback = null, ?callable $defau return $this; } + public function upsert(array $values, array|null $update = null) + { + if (!\is_array(reset($values))) { + $values = [$values]; + } + + if (\is_null($update)) { + $update = array_keys($values[0]); + } + + $this->bindings = []; + $columns = array_keys($values[0]); + ksort($columns); + $createdAt = property_exists($this->_model, 'timestamps') && $this->_model->timestamps && !\in_array('created_at', $columns); + if ($createdAt) { + $columns[] = 'created_at'; + } + $sql = 'INSERT INTO ' . $this->table; + $sql .= ' (' . implode(', ', $columns) . ')'; + + $sql .= ' VALUES '; + $insertAbleValues = []; + foreach ($values as $row) { + ksort($row); + if ($createdAt) { + $row['created_at'] = $this->currentTimestamp(); + } + + $rowValues = array_values($row); + $insertAbleValues[] = ' (' + . implode( + ', ', + array_map( + function ($value) { + if (\is_null($value)) { + return 'NULL'; + } + + $this->bindings[] = $value; + + return $this->getValueType($value); + }, + $rowValues + ) + ) . ')'; + } + + $sql .= empty($insertAbleValues) ? ' default values' : ' ' . implode(',', $insertAbleValues); + $sql .= ' ON DUPLICATE KEY UPDATE '; + if (\array_key_exists('created_at', $update)) { + $update = array_diff($update, ['created_at']); + $update[] = 'updated_at'; + } + $update = array_map(function ($column) { + if ($column === 'updated_at') { + return $column . ' = VALUES(created_at)'; + } + + return $column . ' = VALUES(' . $column . ')'; + }, $update); + $sql .= implode(', ', $update); + $sql .= ';'; + + return $this->raw($sql, $this->bindings); + } + /** * Process conditions * @@ -1503,70 +1569,6 @@ function ($value) { return false; } - public function upsert(array $values, array|null $update = null) - { - if(!is_array(reset($values))) { - $values = [$values]; - } - - if (is_null($update)) { - $update = array_keys($values[0]); - } - - $this->bindings = []; - $columns = array_keys($values[0]); - ksort($columns); - $createdAt = property_exists($this->_model, 'timestamps') && $this->_model->timestamps && !in_array('created_at', $columns); - if ($createdAt) { - $columns[] = 'created_at'; - } - $sql = 'INSERT INTO ' . $this->table; - $sql .= ' (' . implode(', ', $columns) . ')'; - - $sql .= ' VALUES '; - $insertAbleValues = []; - foreach ($values as $row) { - ksort($row); - if ($createdAt) { - $row['created_at'] = $this->currentTimestamp(); - } - - $rowValues = array_values($row); - $insertAbleValues[] = ' (' - . implode( - ', ', - array_map( - function ($value) { - if (\is_null($value)) { - return 'NULL'; - } - - $this->bindings[] = $value; - - return $this->getValueType($value); - }, - $rowValues - ) - ) . ')'; - } - - $sql .= empty($insertAbleValues) ? ' default values' : ' ' . implode(',', $insertAbleValues); - $sql .= ' ON DUPLICATE KEY UPDATE '; - if (array_key_exists('created_at', $update)) { - $update = array_diff($update, ['created_at']); - $update[] = 'updated_at'; - } - $update = array_map(function($column) { - if ($column === 'updated_at') { - return $column . ' = VALUES(created_at)'; - } - return $column . ' = VALUES(' . $column . ')'; - }, $update); - $sql .= implode(', ', $update); - $sql .= ';'; - return $this->raw($sql, $this->bindings); - } - /** * Table alias for select query * diff --git a/src/Relations.php b/src/Relations.php index fe22244..4ab721a 100644 --- a/src/Relations.php +++ b/src/Relations.php @@ -120,6 +120,16 @@ public function getRelationalKeys() return $this->_relationKeys; } + /** + * Returns the {localKey, foreignKey} pair for this model's active relation. + * + * @return array + */ + public function getActiveRelationKey() + { + return $this->getRelationalKeys()[$this->getRelateAs()]; + } + /** * Prepares relations to query * @@ -149,201 +159,6 @@ public function prepareRelation(array $relations): array return $preparedRelation; } - /** - * Adds relation for model - * - * @param string|array $relation - * @param Closure $callback - * - * @return $this - */ - public function with($relation, $callback = null) - { - $relations = []; - if ($callback instanceof Closure) { - $relations = $this->prepareRelation([$relation => $callback]); - } else { - $relations = $this->prepareRelation( - \is_string($relation) ? [$relation => null] : \func_get_args() - ); - } - - $this->addRelation($relations); - - return $this; - } - - /** - * Adds count as sub query in this query - * - * @param string - * @param mixed $relation - * - * @return $this - */ - public function withCount($relation) - { - return $this->withAggregate(\is_array($relation) ? $relation : \func_get_args() , '*', 'count'); - } - - /** - * Adds min as sub query in this query - * - * @param string - * @param mixed $relation - * - * @return $this - */ - public function withMin($relation) - { - return $this->withAggregate(\is_array($relation) ? $relation : \func_get_args() , '*', 'min'); - } - - /** - * Adds max as sub query in this query - * - * @param string - * @param mixed $relation - * - * @return $this - */ - public function withMax($relation) - { - return $this->withAggregate(\is_array($relation) ? $relation : \func_get_args() , '*', 'max'); - } - - /** - * Adds avg as sub query in this query - * - * @param string - * @param mixed $relation - * - * @return $this - */ - public function withAvg($relation) - { - return $this->withAggregate(\is_array($relation) ? $relation : \func_get_args() , '*', 'avg'); - } - - /** - * Adds sum as sub query in this query - * - * @param string - * @param mixed $relation - * - * @return $this - */ - public function withSum($relation) - { - return $this->withAggregate(\is_array($relation) ? $relation : \func_get_args() , '*', 'sum'); - } - - /** - * Adds exists as sub query in this query - * - * @param string - * @param mixed $relation - * - * @return $this - */ - public function withExists($relation) - { - return $this->withAggregate(\is_array($relation) ? $relation : \func_get_args() , '*', 'exists'); - } - - /** - * Adds aggregate sub query to this query - * - * @param array $relation - * @param mixed $column - * @param mixed $function - * - * @return $this - */ - public function withAggregate($relation, $column, $function) - { - if (empty($relation)) { - return $this; - } - - if (empty($this->getQueryBuilder()->select)) { - $this->getQueryBuilder()->select = ["`{$this->getTable()}`.*"]; - } - - if ($column !== '*') { - $column = $this->getQueryBuilder()->prepareColumnName($column); - } - foreach ($this->prepareRelation(\is_array($relation) ? $relation : [$relation]) as $relationName => $relationalQuery) { - [$name, $alias] = $this->prepareRelationName($relationName); - if (\is_null($alias)) { - $alias = strtolower($name . '_' . $function); - } - - $relationKey = $relationalQuery->getModel() - ->getRelationalKeys()[$relationalQuery->getModel()->getRelateAs()]; - $relationalQuery->whereRaw($this->getQueryBuilder()->prepareColumnName($relationKey['localKey']) . '=' . $relationalQuery->prepareColumnName($relationKey['foreignKey'])); - - if ($function === 'exists') { - $query = $relationalQuery->select($column)->prepare(); - $this->getQueryBuilder()->selectRaw("exists({$query}) as `{$alias}`")->withCast([$alias => 'bool']); - } else { - $query = $relationalQuery->selectRaw(sprintf('%s(%s)', $function, $column))->prepare(); - $this->getQueryBuilder()->selectRaw("({$query}) as `{$alias}`"); - } - } - - return $this; - } - - public function whereHas($relation, $callback = null) - { - $relations = []; - if ($callback instanceof Closure) { - $relations = $this->prepareRelation([$relation => $callback]); - } else { - $relations = $this->prepareRelation( - \is_string($relation) ? [$relation => null] : \func_get_args() - ); - } - - foreach ($relations as $relationName => $relationalQuery) { - $relationKey = $relationalQuery->getModel() - ->getRelationalKeys()[$relationalQuery->getModel()->getRelateAs()]; - $relationalQuery->whereRaw($this->getQueryBuilder()->prepareColumnName($relationKey['localKey']) . '=' . $relationalQuery->prepareColumnName($relationKey['foreignKey'])); - - $query = $relationalQuery->select('*')->prepare(); - $this->getQueryBuilder()->whereRaw("exists({$query})"); - } - - return $this; - } - - public function withWhereHas($relation, $callback = null) - { - $relations = []; - if ($callback instanceof Closure) { - $relations = $this->prepareRelation([$relation => $callback]); - } else { - $relations = $this->prepareRelation( - \is_string($relation) ? [$relation => null] : \func_get_args() - ); - } - - foreach ($relations as $relationName => $relationalQuery) { - $relationKey = $relationalQuery->getModel() - ->getRelationalKeys()[$relationalQuery->getModel()->getRelateAs()]; - $newQuery = $relationalQuery->newQuery(); - $newQuery->whereRaw($this->getQueryBuilder()->prepareColumnName($relationKey['localKey']) . '=' . $newQuery->prepareColumnName($relationKey['foreignKey'])); - - $query = $newQuery->select('*')->prepare(); - $this->getQueryBuilder()->whereRaw("exists({$query})"); - } - - $this->addRelation($relations); - - return $this; - } - public function prepareRelationName(string $relationName): array { $name = $relationName; @@ -377,8 +192,7 @@ private function retrieveRelateData(QueryBuilder $query) if (\count($relations) > 0) { foreach ($relations as $relationName => $relationQuery) { $parentQuery = clone $query; - $relationKey = $relationQuery->getModel() - ->getRelationalKeys()[$relationQuery->getModel()->getRelateAs()]; + $relationKey = $relationQuery->getModel()->getActiveRelationKey(); $relationQuery->whereRaw( $relationKey['foreignKey'] @@ -404,8 +218,7 @@ private function setRelatedData(Model $model) $relations = $this->getRelations(); if (\count($relations) > 0) { foreach ($relations as $relationName => $relationQuery) { - $relationKey = $relationQuery->getModel() - ->getRelationalKeys()[$relationQuery->getModel()->getRelateAs()]; + $relationKey = $relationQuery->getModel()->getActiveRelationKey(); $data = isset( $this->_relatedData[$relationName][$model->getAttribute($relationKey['localKey'])] diff --git a/tests/BuilderEntryTest.php b/tests/BuilderEntryTest.php new file mode 100644 index 0000000..e7af10d --- /dev/null +++ b/tests/BuilderEntryTest.php @@ -0,0 +1,41 @@ +assertInstanceOf(QueryBuilder::class, User::query()); + } + + public function testQueryReturnsIndependentInstances(): void + { + $this->assertNotSame(User::query(), User::query()); + } + + public function testQueryIsChainable(): void + { + $sql = User::query()->where('id', 1)->toSql(); + + $this->assertStringContainsString('FROM wp_users', $sql); + $this->assertStringContainsString('WHERE', $sql); + } + + public function testQueryChainsRelationMethods(): void + { + $this->assertSame( + User::withCount('posts')->toSql(), + User::query()->withCount('posts')->toSql() + ); + } +} diff --git a/tests/EagerLoadIntegrationTest.php b/tests/EagerLoadIntegrationTest.php new file mode 100644 index 0000000..16a0828 --- /dev/null +++ b/tests/EagerLoadIntegrationTest.php @@ -0,0 +1,55 @@ +resolver = function ($sql) { + if (strpos($sql, 'wp_posts') !== false) { + return [ + (object) ['id' => 10, 'user_id' => 1], + (object) ['id' => 11, 'user_id' => 1], + (object) ['id' => 12, 'user_id' => 2], + ]; + } + + return [ + (object) ['id' => 1], + (object) ['id' => 2], + ]; + }; + } + + protected function tearDown(): void + { + $GLOBALS['wpdb'] = new FakeWpdb(); + } + + public function testStaticWithEagerLoadsAndGroupsRelatedRows(): void + { + $users = User::with('posts')->get(); + + $this->assertInstanceOf(Collection::class, $users); + $this->assertCount(2, $users); + + $first = $users[0]; + $second = $users[1]; + + $this->assertCount(2, $first->posts, 'user 1 should have 2 posts'); + $this->assertCount(1, $second->posts, 'user 2 should have 1 post'); + $this->assertInstanceOf(Post::class, $first->posts[0]); + } +} diff --git a/tests/Fixtures/Post.php b/tests/Fixtures/Post.php new file mode 100644 index 0000000..22ffb4e --- /dev/null +++ b/tests/Fixtures/Post.php @@ -0,0 +1,14 @@ +hasMany(Post::class, 'user_id', 'id'); + } +} diff --git a/tests/RelationDefinitionRegressionTest.php b/tests/RelationDefinitionRegressionTest.php new file mode 100644 index 0000000..56a91e4 --- /dev/null +++ b/tests/RelationDefinitionRegressionTest.php @@ -0,0 +1,48 @@ +assertInstanceOf(QueryBuilder::class, (new User())->posts()); + } + + public function testStaticBuilderChainStillWorks(): void + { + $sql = User::where('id', '>', 0)->toSql(); + + $this->assertStringContainsString('FROM wp_users', $sql); + $this->assertStringContainsString('WHERE', $sql); + } + + public function testGetReturnsCollectionForMultipleRows(): void + { + $GLOBALS['wpdb']->queueResult([ + (object) ['id' => 1], + (object) ['id' => 2], + ]); + + $result = User::where('id', '>', 0)->get(); + + $this->assertInstanceOf(Collection::class, $result); + $this->assertCount(2, $result); + } +} diff --git a/tests/RelationStaticAccessTest.php b/tests/RelationStaticAccessTest.php new file mode 100644 index 0000000..b017182 --- /dev/null +++ b/tests/RelationStaticAccessTest.php @@ -0,0 +1,121 @@ +with(...)`), and must + * produce identical SQL either way. Moving them onto QueryBuilder (forwarded + * from Model via __call/__callStatic) is what makes the static form legal — + * PHP only routes through __callStatic for methods that are not defined on the + * class itself. + */ +final class RelationStaticAccessTest extends TestCase +{ + /** Relation entry methods that take a single relation name. */ + public static function singleRelationMethods(): array + { + return [ + 'with' => ['with'], + 'withCount' => ['withCount'], + 'withMin' => ['withMin'], + 'withMax' => ['withMax'], + 'withAvg' => ['withAvg'], + 'withSum' => ['withSum'], + 'withExists' => ['withExists'], + 'whereHas' => ['whereHas'], + 'withWhereHas' => ['withWhereHas'], + ]; + } + + #[DataProvider('singleRelationMethods')] + public function testRelationMethodIsCallableStatically(string $method): void + { + $builder = User::$method('posts'); + + $this->assertInstanceOf( + QueryBuilder::class, + $builder, + "Model::{$method}() must be callable statically and return a QueryBuilder" + ); + } + + #[DataProvider('singleRelationMethods')] + public function testRelationMethodIsCallableOnInstance(string $method): void + { + $builder = (new User())->{$method}('posts'); + + $this->assertInstanceOf(QueryBuilder::class, $builder); + } + + #[DataProvider('singleRelationMethods')] + public function testStaticAndInstanceProduceIdenticalSql(string $method): void + { + $static = User::$method('posts')->toSql(); + $instance = (new User())->{$method}('posts')->toSql(); + + $this->assertSame($instance, $static); + } + + public function testWithRegistersRelationOnModelStatically(): void + { + $builder = User::with('posts'); + + $this->assertArrayHasKey('posts', $builder->getModel()->getRelations()); + } + + public function testWithCountStaticSqlMatchesBaseline(): void + { + $expected = 'SELECT `wp_users`.*, (SELECT count(*) FROM wp_posts WHERE ' + . '`wp_users`.`id`=`wp_posts`.`user_id`) as `posts_count` FROM wp_users'; + + $this->assertSame($expected, User::withCount('posts')->toSql()); + } + + public function testWhereHasStaticSqlMatchesBaseline(): void + { + $expected = 'SELECT FROM wp_users WHERE exists(SELECT `wp_posts`.* FROM ' + . 'wp_posts WHERE `wp_users`.`id`=`wp_posts`.`user_id`)'; + + $this->assertSame($expected, User::whereHas('posts')->toSql()); + } + + public function testWithExistsStaticSqlMatchesBaseline(): void + { + $expected = 'SELECT `wp_users`.*, exists(SELECT `wp_posts`.* FROM wp_posts ' + . 'WHERE `wp_users`.`id`=`wp_posts`.`user_id`) as `posts_exists` FROM wp_users'; + + $this->assertSame($expected, User::withExists('posts')->toSql()); + } + + public function testWithIsChainableAfterBuilderMethod(): void + { + $builder = User::where('id', '>', 0)->with('posts'); + + $this->assertInstanceOf(QueryBuilder::class, $builder); + $this->assertArrayHasKey('posts', $builder->getModel()->getRelations()); + } + + public function testWithAcceptsArrayOfRelations(): void + { + $relations = User::with(['posts'])->getModel()->getRelations(); + + $this->assertArrayHasKey('posts', $relations); + } + + public function testWithSupportsClosureConstraintStatically(): void + { + $builder = User::with('posts', function (QueryBuilder $query) { + $query->where('status', 'published'); + }); + + $this->assertArrayHasKey('posts', $builder->getModel()->getRelations()); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..e21fb37 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,119 @@ +last_result = $rows; + } + + public function query($sql) + { + $this->last_query = $sql; + $this->queries[] = $sql; + + if (\is_callable($this->resolver)) { + $this->last_result = ($this->resolver)($sql); + } + + return $this->rows_affected; + } + + public function prepare($query, ...$args) + { + if (\count($args) === 1 && \is_array($args[0])) { + $args = $args[0]; + } + + $index = 0; + + return preg_replace_callback( + '/%[dsfF]/', + function ($match) use (&$index, $args) { + $value = $args[$index] ?? ''; + $index++; + + return is_numeric($value) ? (string) $value : "'" . $value . "'"; + }, + $query + ); + } + + public function get_results($query) + { + return $this->last_result; + } +} + +$GLOBALS['wpdb'] = new FakeWpdb(); + +require __DIR__ . '/../vendor/autoload.php'; +require __DIR__ . '/Fixtures/Post.php'; +require __DIR__ . '/Fixtures/User.php'; From 0e8bb99be85ccd10afc736d2c873705de51a35b6 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Sun, 28 Jun 2026 18:21:28 +0600 Subject: [PATCH 038/102] docs: add comprehensive usage guide Add docs/usage.md covering models, CRUD, the query builder, collections, casts, relationships and aggregates, model events, transactions, raw queries and the schema builder. Rewrite the README stub into a quick-start that links the usage guide and breaking-changes notes. - fix paginate() @method tag (arg order was reversed vs the real signature) Assisted-By: AI --- README.md | 48 +++- docs/usage.md | 643 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/Model.php | 2 +- 3 files changed, 680 insertions(+), 13 deletions(-) create mode 100644 docs/usage.md diff --git a/README.md b/README.md index b77953a..e31b7bc 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,46 @@ -### WPKit/Database +# wp-database ---- +A small ActiveRecord ORM with a fluent query and schema builder on top of +WordPress' `$wpdb`. -# usage +## Install -1. Add this repository in composer.json +```jsonc +// composer.json +"repositories": [ + { "type": "vcs", "url": "https://github.com/Bit-Apps-Pro/wp-database" } +] +``` +```bash +composer require bitapps/wp-database:dev-main ``` -"repositories": [ + +## Quick start + +```php +use BitApps\WPDatabase\Model; + +class Contact extends Model +{ + protected $fillable = ['name', 'email']; + + public function deals() { - "type": "vcs", - "url": "https://github.com/Bit-Apps-Pro/wp-database" + return $this->hasMany(Deal::class, 'contact_id', 'id'); } - ] -``` +} -2. Then install the package +Contact::insert(['name' => 'Ada', 'email' => 'ada@x.com']); +$active = Contact::where('is_active', 1) + ->withCount('deals') + ->orderBy('name')->asc() + ->get(); // Collection of Contact models ``` -composer require bitapps/wp-database:dev-main -``` + +## Documentation + +- **[Usage guide](docs/usage.md)** — models, query builder, relationships, + casts, events, transactions, schema builder, and more. +- **[Breaking changes](docs/breaking-changes.md)** — upgrade notes. diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 0000000..e666067 --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,643 @@ +# wp-database — Usage Guide + +A small ActiveRecord ORM + fluent query/schema builder on top of WordPress' +`$wpdb`. Models map to tables, queries are built fluently, results come back as +hydrated model objects inside a `Collection`. + +- [Install](#install) +- [Setup](#setup) +- [Defining models](#defining-models) +- [Creating records](#creating-records) +- [Reading records](#reading-records) +- [The query builder](#the-query-builder) +- [Collections](#collections) +- [Updating records](#updating-records) +- [Deleting records](#deleting-records) +- [Upsert](#upsert) +- [Attribute casting](#attribute-casting) +- [Relationships](#relationships) +- [Relation aggregates & existence](#relation-aggregates--existence) +- [Model events](#model-events) +- [Transactions](#transactions) +- [Raw queries](#raw-queries) +- [Schema builder](#schema-builder) +- [Static vs instance calls (IDE notes)](#static-vs-instance-calls-ide-notes) +- [Breaking changes](#breaking-changes) + +--- + +## Install + +Add the repository and require the package: + +```jsonc +// composer.json +"repositories": [ + { "type": "vcs", "url": "https://github.com/Bit-Apps-Pro/wp-database" } +] +``` + +```bash +composer require bitapps/wp-database:dev-main +``` + +Namespace: `BitApps\WPDatabase`. (When bundled into a plugin with Strauss/ +php-scoper the namespace is prefixed, e.g. `BitApps\YourPlugin\Deps\BitApps\WPDatabase`.) + +--- + +## Setup + +The package wraps the global `$wpdb`, so it works inside WordPress with no +connection config. Optionally set a plugin-specific table prefix once during +boot — it is appended after the WordPress prefix (`wp_` + plugin prefix): + +```php +use BitApps\WPDatabase\Connection; + +Connection::setPluginPrefix('myplugin_'); // tables resolve as wp_myplugin_* +``` + +Other `Connection` helpers: + +```php +Connection::getPrefix(); // wp_ + plugin prefix +Connection::wpPrefix(); // wp_ +Connection::enableQuery(); // start collecting executed queries +Connection::queries(); // array of executed queries (after enableQuery) +Connection::errors(); // array of query errors +``` + +You can also pin a timezone for generated timestamps (`created_at`/`updated_at`): + +```php +use BitApps\WPDatabase\QueryBuilder; + +QueryBuilder::$TIME_ZONE = 'UTC'; // otherwise wp_timezone_string()/gmt_offset is used +``` + +--- + +## Defining models + +Extend `Model`. Table name is auto-derived from the class name (pluralised, +snake_cased) unless `$table` is set. The WordPress + plugin prefix is added +automatically. + +```php +use BitApps\WPDatabase\Model; + +class Contact extends Model +{ + protected $table = 'contacts'; // optional; default derived from class name + protected $primaryKey = 'id'; // default 'id' + protected $prefix = ''; // per-model extra prefix (default plugin prefix) + + public $timestamps = true; // auto-maintain created_at / updated_at + + // Mass-assignment allow-list. Omit to allow all attributes. + protected $fillable = ['first_name', 'last_name', 'email', 'meta']; + + // Attribute casts (see "Attribute casting"). + protected $casts = [ + 'meta' => 'array', + 'is_active' => 'bool', + ]; + + // Enable soft deletes (requires a deleted_at column). + public $soft_deletes = true; +} +``` + +| Property | Purpose | +|---|---| +| `$table` | Table name (without prefix). Auto-derived if unset. | +| `$primaryKey` | Primary key column (default `id`). | +| `$prefix` | Extra per-model prefix. | +| `$fillable` | Mass-assignment allow-list. Unset = allow all. | +| `$casts` | Map of column → cast type. | +| `$timestamps` | Auto-set `created_at`/`updated_at` (default `true`). | +| `$soft_deletes` | `delete()` sets `deleted_at` instead of removing the row. | + +--- + +## Creating records + +```php +// Instantiate, set, save +$contact = new Contact(); +$contact->first_name = 'Ada'; +$contact->email = 'ada@example.com'; +$saved = $contact->save(); // returns the saved Model on success, false on failure + +// Mass-create via insert() +$contact = Contact::insert([ + 'first_name' => 'Ada', + 'email' => 'ada@example.com', +]); + +// Bulk insert (array of rows) — returns a Collection of created models +$created = Contact::insert([ + ['first_name' => 'Ada', 'email' => 'ada@x.com'], + ['first_name' => 'Grace','email' => 'grace@x.com'], +]); +``` + +`save()` inserts when the model is new and updates (dirty attributes only) when +it already exists. On success it returns the model; on failure, `false`. + +--- + +## Reading records + +```php +Contact::find(1); // by primary key → single Model (or false) +Contact::find(['email' => 'a@x.com']); // by attributes +Contact::findOne(['email' => 'a@x.com']); + +Contact::first(); // first row +Contact::all(); // all rows → Collection +Contact::get(); // all rows → Collection +Contact::get(['id', 'email']); // only some columns + +Contact::where('is_active', 1)->get(); // filtered → Collection +``` + +- A query returning **multiple** rows yields a [`Collection`](#collections). +- A single-row read (e.g. `find` by PK, `first`) yields a `Model`. +- An empty result yields `[]`. + +--- + +## The query builder + +Every static call on a model opens a builder; chain freely. Use `toSql()` to +inspect the SQL without executing. + +### Select + +```php +Contact::select('id', 'email')->get(); +Contact::select(['id', 'email'])->get(); +Contact::addSelect('phone')->get(); // add to existing select +Contact::selectRaw('COUNT(*) as total')->get(); // raw expression (NOT select()) +Contact::selectRaw('SUM(amount) as amt', [])->get(); +``` + +> Pass raw SQL expressions to `selectRaw()`, not `select()` — `select()` +> back-tick-quotes its arguments as column identifiers. + +### Where + +```php +Contact::where('email', 'a@x.com')->get(); // col = value +Contact::where('age', '>=', 18)->get(); // col op value +Contact::where('id', [1, 2, 3])->get(); // value array → IN (...) +Contact::where('deleted_at', null)->get(); // null → IS NULL + +Contact::where('a', 1)->where('b', 2)->get(); // AND +Contact::where('a', 1)->orWhere('b', 2)->get(); // OR + +// Grouped / nested conditions via a closure +Contact::where('active', 1) + ->where(function ($q) { + $q->where('city', 'NYC')->orWhere('city', 'LA'); + }) + ->get(); + +// Helpers +Contact::whereIn('id', [1, 2, 3])->get(); +Contact::whereNull('deleted_at')->get(); +Contact::whereNotNull('email')->get(); +Contact::whereBetween('age', 18, 65)->get(); +Contact::orWhereBetween('score', 0, 50)->get(); + +// Raw +Contact::whereRaw('YEAR(created_at) = %d', [2026])->get(); +Contact::orWhereRaw('email LIKE %s', ['%@x.com'])->get(); +``` + +### Conditional clauses + +```php +Contact::query() + ->when($search, fn ($q, $search) => $q->whereRaw('name LIKE %s', ["%{$search}%"])) + ->when($onlyActive, fn ($q) => $q->where('is_active', 1), fn ($q) => $q->where('is_active', 0)) + ->get(); +``` + +### Ordering, grouping, having + +```php +Contact::orderBy('created_at')->desc()->get(); +Contact::orderBy('name')->asc()->get(); +Contact::orderByRaw('FIELD(status, %s, %s)', ['new', 'open'])->get(); + +Contact::groupBy('city')->having('city', '!=', '')->get(); +``` + +### Joins + +```php +Contact::query() + ->join('orders', 'orders.contact_id', '=', 'contacts.id') + ->leftJoin('notes', 'notes.contact_id', '=', 'contacts.id') + ->get(); +// also: rightJoin(), fullJoin(), crossJoin(), on(), orOn() +``` + +### Limit / offset / pagination + +```php +Contact::take(10)->skip(20)->get(); // LIMIT 10 OFFSET 20 + +$page = Contact::where('is_active', 1)->paginate($pageNo = 1, $perPage = 20); +// [ +// 'data' => Collection, +// 'pages' => 5, +// 'total' => 93, +// 'current_total' => 20, +// 'current_page' => 1, +// 'last_page' => 5, +// 'per_page' => 20, +// ] +``` + +### Aggregates + +```php +Contact::where('is_active', 1)->count(); // int|null +Contact::max('score'); // mixed +Contact::min('score'); // mixed +``` + +### Inspecting the SQL + +```php +Contact::where('is_active', 1)->toSql(); // build the SQL string, do not run +Contact::where('is_active', 1)->prepare(); // prepared SQL (placeholders bound) +``` + +--- + +## Collections + +Multi-row reads return `BitApps\WPDatabase\Collection`, which implements +`ArrayAccess`, `IteratorAggregate`, `Countable` and `JsonSerializable` — so +`foreach`, `$c[0]`, `count($c)` and `json_encode($c)` all work. + +```php +$contacts = Contact::where('is_active', 1)->get(); + +$contacts->map(fn ($c) => $c->email); +$contacts->filter(fn ($c) => $c->score > 50); +$contacts->pluck('email'); // Collection of emails +$contacts->first(); // first item (optional callback + default) +$contacts->last(); +$contacts->reduce(fn ($carry, $c) => $carry + $c->score, 0); +$contacts->reverse(); +$contacts->all(); // underlying plain array +$contacts->toArray(); // array of model arrays +$contacts->count(); +``` + +--- + +## Updating records + +```php +// On an existing model — updates dirty attributes only +$contact = Contact::find(1); +$contact->email = 'new@x.com'; +$contact->save(); + +// Conditional bulk update — executes immediately +Contact::where('is_active', 0)->update(['status' => 'archived']); +``` + +> `update()` executes immediately (it is not chainable). Set conditions +> **before** calling it. + +--- + +## Deleting records + +```php +Contact::where('id', 1)->delete(); // DELETE ... WHERE id = 1 + +$contact = Contact::find(1); +$contact->delete(); // deletes that row + +Contact::destroy([1, 2, 3]); // delete by primary keys +``` + +- A `delete()` that produces **no WHERE clause** throws + `RuntimeException('SQL query is empty')` — a guard against wiping the table. + Use a raw `TRUNCATE` if you really mean to empty it. +- With `$soft_deletes = true`, `delete()` sets `deleted_at` instead of removing + the row. + +--- + +## Upsert + +Insert rows, updating on duplicate key. Pass a single row or an array of rows; +the optional second argument lists the columns to update on conflict (defaults +to all provided columns). + +```php +Contact::query()->upsert( + ['email' => 'a@x.com', 'first_name' => 'Ada'], // unique key: email + ['first_name'] // columns to update on duplicate +); + +Contact::query()->upsert([ + ['email' => 'a@x.com', 'first_name' => 'Ada'], + ['email' => 'b@x.com', 'first_name' => 'Bob'], +]); +``` + +--- + +## Attribute casting + +Declare `$casts` to convert attribute values on read. Supported types: + +| Cast | Result | +|---|---| +| `int` | `(int)` value | +| `string` | `(string)` value | +| `bool` / `boolean` | `(bool)` value | +| `array` | `json_decode($value, true)` | +| `object` | `json_decode($value)` | +| `date` | `DateTime` from `Y-m-d H:i:s` | + +```php +protected $casts = [ + 'meta' => 'array', + 'is_active' => 'bool', + 'created_at' => 'date', +]; +``` + +`NULL` values are returned as `null` (not cast). Add casts at runtime with +`withCast()`: + +```php +Contact::query()->withCast(['is_active' => 'bool'])->get(); +``` + +--- + +## Relationships + +Define relationships as methods on the model. The relation method returns a +query for the related model. + +```php +class Deal extends Model +{ + public function contact() + { + return $this->belongsTo(Contact::class, 'contact_id', 'id'); + } +} + +class Contact extends Model +{ + public function deals() + { + return $this->hasMany(Deal::class, 'contact_id', 'id'); + } + + public function profile() + { + return $this->hasOne(Profile::class, 'contact_id', 'id'); + } +} +``` + +Available: `hasOne()`, `hasMany()`, `belongsTo()`, `belongsToMany()` — each +takes `($model, $foreignKey = null, $localKey = null)`. + +### Eager loading + +```php +Contact::with('deals')->get(); // load deals for each contact +Contact::with(['deals', 'profile'])->get(); // multiple relations + +// Constrain the relation +Contact::with('deals', function ($q) { + $q->where('status', 'open'); +})->get(); + +// Alias the loaded relation +Contact::with('deals as open_deals')->get(); + +// Access after loading +foreach (Contact::with('deals')->get() as $contact) { + foreach ($contact->deals as $deal) { /* ... */ } +} +``` + +--- + +## Relation aggregates & existence + +```php +Contact::withCount('deals')->get(); // adds `deals_count` +Contact::withSum('deals.amount')->get(); +Contact::withAvg('deals.amount')->get(); +Contact::withMin('deals.amount')->get(); +Contact::withMax('deals.amount')->get(); +Contact::withExists('deals')->get(); // adds bool-cast `deals_exists` + +// Filter by relation existence +Contact::whereHas('deals')->get(); +Contact::whereHas('deals', fn ($q) => $q->where('status', 'open'))->get(); + +// Filter by existence AND eager-load the same relation +Contact::withWhereHas('deals', fn ($q) => $q->where('status', 'open'))->get(); +``` + +Aggregate columns are aliased `_` by default (e.g. +`deals_count`); use `'deals as x'` to alias. + +--- + +## Model events + +Models fire lifecycle events. Register handlers in a static `boot()` using the +event registrars. A pre-event (`saving`, `updating`, `deleting`) that returns +`false` aborts the operation. + +```php +class Contact extends Model +{ + protected static function boot() + { + static::saving(function ($model) { + if (!$model->email) { + return false; // abort the save + } + }); + + static::created(fn ($model) => Log::info("created {$model->id}")); + + // A handler can also be a class with a handle() method + static::deleted(SyncDeletion::class); + } +} +``` + +Events: `booting`, `booted`, `retrieved`, `saving`, `saved`, `creating`, +`created`, `updating`, `updated`, `deleting`, `deleted`. + +> The `HasEvents` trait reserves the method names `boot`, `booting`, `booted`, +> `fireEvent`, `fireCustomEvent`, `registerEvent` and the properties `$events`, +> `$registeredEvents`, `$booted`. + +--- + +## Transactions + +```php +use BitApps\WPDatabase\Connection; + +Connection::startTransaction(); +try { + Contact::insert([...]); + Deal::insert([...]); + Connection::commit(); +} catch (\Throwable $e) { + Connection::rollback(); + throw $e; +} +``` + +> The equivalent methods on `QueryBuilder` are deprecated — use `Connection`. + +--- + +## Raw queries + +```php +// SELECT → returns result rows +Contact::query()->raw('SELECT * FROM wp_contacts WHERE id = %d', [1]); + +// Non-SELECT → returns the wpdb query result +Contact::query()->raw('UPDATE wp_contacts SET is_active = 1 WHERE id = %d', [1]); +``` + +Placeholders use `$wpdb` conventions (`%d`, `%s`, `%f`) with the bindings array. + +--- + +## Schema builder + +Use `Schema` (a facade over `Blueprint`) to create, alter and drop tables. The +table prefix is applied automatically. + +```php +use BitApps\WPDatabase\Schema; + +Schema::create('contacts', function ($table) { + $table->id(); // BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY + $table->string('email'); // VARCHAR(255) + $table->string('first_name')->nullable(); + $table->int('age')->unsigned()->defaultValue(0); + $table->text('bio')->nullable(); + $table->bool('is_active')->defaultValue(1); + $table->json('meta')->nullable(); + + $table->unique('email'); // unique index + $table->unique(['first_name', 'last_name']); // composite unique + $table->index(); // index last column + + $table->timestamps(); // created_at + updated_at + $table->softDeletes(); // deleted_at +}); +``` + +### Column types + +`char`, `varchar`, `json`, `binary`, `varbinary`, `tinyblob`, `tinytext`, +`text`, `blob`, `mediumtext`, `mediumblob`, `longtext`, `longblob`, `enum`, +`set`, `bit`, `tinyint`, `bool`/`boolean`, `smallint`, `mediumint`, +`int`/`integer`, `bigint`, `float`, `double`, `double_precision`, `decimal`/ +`dec`, `date`, `datetime`, `timestamp`, `time`, `year`. Helpers: `id()`, +`increments()`, `string()`, `timestamps()`, `softDeletes()`. + +### Column modifiers + +```php +$table->int('views')->unsigned()->zeroFill(); +$table->string('slug')->nullable()->defaultValue(''); +$table->bigint('user_id')->primary(); +$table->decimal('price')->length([10, 2]); +``` + +`nullable()`, `defaultValue($v)`, `unsigned()`, `zeroFill()`, `primary()`, +`unique($column = null)`, `index($type = null)`, `length($n)`. + +### Foreign keys + +```php +Schema::create('deals', function ($table) { + $table->id(); + $table->bigint('contact_id')->unsigned(); + $table->foreign('contacts', 'id')->onDelete()->cascade(); + // FK on the previously-defined column (contact_id) → contacts(id) +}); +``` + +`foreign($referencedTable, $referencedColumn)` applies to the **last defined +column**, then one of `cascade()`, `restrict()`, `setNull()`, optionally scoped +by `onDelete()` / `onUpdate()`. + +### Altering & dropping + +```php +Schema::drop('contacts'); +Schema::rename('contacts', 'people'); + +Schema::edit('contacts', function ($table) { + $table->string('phone')->nullable(); // ADD COLUMN + $table->dropColumn('bio'); + $table->dropTimestamps(); + $table->dropIndex(['email_UNIQUE']); + $table->dropForeign(['fk_name']); + $table->dropPrimary(); +}); + +// Scope to a custom prefix +Schema::withPrefix('wp_other_')->create('logs', function ($table) { /* ... */ }); +``` + +--- + +## Static vs instance calls (IDE notes) + +All builder/relation methods can be called three ways: + +```php +Contact::where('id', 1)->get(); // static (magic via __callStatic) +Contact::query()->where('id', 1)->get(); // real static entry — best IDE support +(new Contact())->where('id', 1)->get(); // instance +``` + +- `Model::query()` is a **real** static method returning a `QueryBuilder`; start + chains with it for autocomplete **and** Ctrl/Cmd+Click navigation that work in + every editor (PhpStorm and VS Code/Intelephense). +- Terse static calls (`Contact::where()`) are forwarded through + `__call`/`__callStatic`. `@method static` docblocks give autocomplete; the + `@mixin QueryBuilder` annotation gives PhpStorm navigation to the real method. + +--- + +## Breaking changes + +If you are upgrading, read [breaking-changes.md](breaking-changes.md) — it +covers the `Collection` return type, `update()`/`save()` behavior, `select()` +quoting, the `delete()`-without-WHERE guard, null casting, and the relation +method relocation. diff --git a/src/Model.php b/src/Model.php index 489199b..b3cca21 100644 --- a/src/Model.php +++ b/src/Model.php @@ -49,7 +49,7 @@ * @method static QueryBuilder whereBetween($column, $start, $end) * @method static QueryBuilder orWhereBetween($column, $start, $end) * @method static QueryBuilder when($value = null, ?callable $callback = null, ?callable $default = null) - * @method static QueryBuilder paginate($perPage = 10, $pageNo = 0) + * @method static array paginate($pageNo = 0, $perPage = 10) * @method static QueryBuilder groupBy($columns) * @method static QueryBuilder having(...$params) * @method static QueryBuilder orHaving(...$params) From 169539b7f1c808ed441d94bdc7d1a57ba0964f95 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Sun, 28 Jun 2026 19:38:54 +0600 Subject: [PATCH 039/102] Chore: php-cs-fixer to export-ignore --- .gitattributes | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitattributes b/.gitattributes index 08f4280..3ed6878 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,5 +3,6 @@ /.vscode export-ignore lefthook.yml export-ignore +.php-cs-fixer.php export-ignore .gitattributes export-ignore .gitignore export-ignore \ No newline at end of file From 3946aedca88fafeb3c00cb92ba38266b158cbc44 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Mon, 29 Jun 2026 17:05:22 +0600 Subject: [PATCH 040/102] test: add failing tests pinning relation/event/aggregate defects Red tests documenting four confirmed defects to be fixed next: - withMin/withMax/withAvg/withSum ignore the `relation.column` argument (hardcode `*`), so the aggregate is never emitted. - Soft delete builds `SET = NULL` instead of `SET deleted_at = `, and bypasses the no-WHERE guard. - A `saving` handler returning false does not abort the write; `retrieved` fires before the model is hydrated on single-row reads. - count()/min()/aggregate() collapse a genuine 0/'0' to null via !empty(). Adds SoftPost, EventUser, RetrieveUser fixtures and wires them into the bootstrap. Assisted-By: AI --- tests/AggregateZeroTest.php | 42 ++++++++++++++++++++++ tests/Fixtures/EventUser.php | 31 ++++++++++++++++ tests/Fixtures/RetrieveUser.php | 27 ++++++++++++++ tests/Fixtures/SoftPost.php | 16 +++++++++ tests/ModelEventsTest.php | 49 +++++++++++++++++++++++++ tests/RelationAggregateColumnTest.php | 51 +++++++++++++++++++++++++++ tests/SoftDeleteTest.php | 44 +++++++++++++++++++++++ tests/bootstrap.php | 3 ++ 8 files changed, 263 insertions(+) create mode 100644 tests/AggregateZeroTest.php create mode 100644 tests/Fixtures/EventUser.php create mode 100644 tests/Fixtures/RetrieveUser.php create mode 100644 tests/Fixtures/SoftPost.php create mode 100644 tests/ModelEventsTest.php create mode 100644 tests/RelationAggregateColumnTest.php create mode 100644 tests/SoftDeleteTest.php diff --git a/tests/AggregateZeroTest.php b/tests/AggregateZeroTest.php new file mode 100644 index 0000000..6c386d1 --- /dev/null +++ b/tests/AggregateZeroTest.php @@ -0,0 +1,42 @@ +resolver = function () { + return [(object) ['COUNT' => '0']]; + }; + + $this->assertSame(0, User::query()->count()); + } + + public function testMinReturnsZeroValue(): void + { + $GLOBALS['wpdb']->resolver = function () { + return [(object) ['MIN' => '0']]; + }; + + $this->assertSame('0', User::query()->min('score')); + } +} diff --git a/tests/Fixtures/EventUser.php b/tests/Fixtures/EventUser.php new file mode 100644 index 0000000..4130f04 --- /dev/null +++ b/tests/Fixtures/EventUser.php @@ -0,0 +1,31 @@ +id; + }); + } +} diff --git a/tests/Fixtures/SoftPost.php b/tests/Fixtures/SoftPost.php new file mode 100644 index 0000000..67e64a2 --- /dev/null +++ b/tests/Fixtures/SoftPost.php @@ -0,0 +1,16 @@ +name = 'Ada'; + + $result = $user->save(); + + $this->assertTrue(EventUser::$savingCalled, 'saving handler should have run'); + $this->assertSame([], $GLOBALS['wpdb']->queries, 'aborted save must not execute any query'); + $this->assertFalse($result, 'aborted save returns false'); + } + + public function testRetrievedHandlerReceivesHydratedModel(): void + { + $GLOBALS['wpdb']->queueResult([(object) ['id' => 7]]); + + RetrieveUser::first(); + + $this->assertSame(7, RetrieveUser::$seenId, 'retrieved must fire after the model is filled'); + } +} diff --git a/tests/RelationAggregateColumnTest.php b/tests/RelationAggregateColumnTest.php new file mode 100644 index 0000000..91d1748 --- /dev/null +++ b/tests/RelationAggregateColumnTest.php @@ -0,0 +1,51 @@ +_`. + */ +final class RelationAggregateColumnTest extends TestCase +{ + protected function tearDown(): void + { + $GLOBALS['wpdb'] = new FakeWpdb(); + } + + /** @return array */ + public static function aggregateMethods(): array + { + return [ + 'withSum' => ['withSum', 'sum'], + 'withMin' => ['withMin', 'min'], + 'withMax' => ['withMax', 'max'], + 'withAvg' => ['withAvg', 'avg'], + ]; + } + + #[DataProvider('aggregateMethods')] + public function testAggregateEmitsFunctionOverRelatedColumn(string $method, string $function): void + { + $sql = User::$method('posts.amount')->toSql(); + + $this->assertStringContainsStringIgnoringCase($function . '(', $sql, "should emit a {$function}() aggregate"); + $this->assertStringContainsString('amount', $sql, 'should aggregate the related `amount` column, not *'); + $this->assertStringContainsString('posts_' . $function, $sql, "should alias the column posts_{$function}"); + } + + #[DataProvider('aggregateMethods')] + public function testAggregateDoesNotAggregateStar(string $method, string $function): void + { + $sql = User::$method('posts.amount')->toSql(); + + $this->assertStringNotContainsStringIgnoringCase($function . '(*)', $sql, "{$function}(*) means the column was ignored"); + } +} diff --git a/tests/SoftDeleteTest.php b/tests/SoftDeleteTest.php new file mode 100644 index 0000000..ffb81c7 --- /dev/null +++ b/tests/SoftDeleteTest.php @@ -0,0 +1,44 @@ +` on the targeted rows and + * (b) honour the same no-WHERE guard as a hard delete, so a soft delete with no + * conditions cannot silently rewrite the whole table. + */ +final class SoftDeleteTest extends TestCase +{ + protected function setUp(): void + { + $GLOBALS['wpdb'] = new FakeWpdb(); + } + + protected function tearDown(): void + { + $GLOBALS['wpdb'] = new FakeWpdb(); + } + + public function testSoftDeleteSetsDeletedAtColumnOnMatchedRows(): void + { + SoftPost::where('id', 1)->delete(); + + $sql = $GLOBALS['wpdb']->last_query; + + $this->assertStringContainsString('UPDATE wp_soft_posts', $sql); + $this->assertMatchesRegularExpression('/SET\s+`?\w*\.?`?deleted_at`?\s*=/i', $sql, 'must assign the deleted_at column'); + $this->assertStringContainsString('WHERE', $sql); + } + + public function testSoftDeleteWithoutWhereIsGuarded(): void + { + $this->expectException(RuntimeException::class); + + SoftPost::query()->delete(); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index e21fb37..6a770fc 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -117,3 +117,6 @@ public function get_results($query) require __DIR__ . '/../vendor/autoload.php'; require __DIR__ . '/Fixtures/Post.php'; require __DIR__ . '/Fixtures/User.php'; +require __DIR__ . '/Fixtures/SoftPost.php'; +require __DIR__ . '/Fixtures/EventUser.php'; +require __DIR__ . '/Fixtures/RetrieveUser.php'; From a5d0d6d0fc83cad787f8711b7fab32a561b098b5 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Mon, 29 Jun 2026 17:52:23 +0600 Subject: [PATCH 041/102] fix: relation aggregates, soft delete, events and zero-collapse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - withMin/withMax/withAvg/withSum now parse the `relation.column` argument and aggregate that column (was hardcoded `*` → silently emitted nothing). - Soft delete writes `deleted_at = ` on matched rows and honours the no-WHERE guard (was malformed `SET = NULL`, and unguarded). - A `saving` handler returning false aborts the write; saving/saved fire on both insert and update; `retrieved` fires after the model is hydrated. - aggregate()/count() no longer collapse a genuine 0/'0' to null; count() returns int; canonical selectRaw reset removes an undefined-key warning. Assisted-By: AI --- src/Model.php | 4 +- src/QueriesRelationships.php | 81 ++++++++++++++++++++++++++++++++---- src/QueryBuilder.php | 38 +++++++++-------- 3 files changed, 97 insertions(+), 26 deletions(-) diff --git a/src/Model.php b/src/Model.php index b3cca21..6679999 100644 --- a/src/Model.php +++ b/src/Model.php @@ -79,7 +79,7 @@ * @method static QueryBuilder withExists(string|array $relation) * @method static QueryBuilder whereHas(string|array $relation, ?Closure $callback = null) * @method static QueryBuilder withWhereHas(string|array $relation, ?Closure $callback = null) - * @method static int|null count() + * @method static int count() * @method static mixed max($column) * @method static mixed min($column) * @method static bool|string delete() @@ -386,11 +386,11 @@ public function getInstanceFromBuilder($result, $setAttribute = false) $this->retrieveRelateData($this->getQueryBuilder()); if (\count($result) == 1 && $setAttribute) { - $this->fireEvent('retrieved'); $this->fill((array) $result[0], true); $this->setExists(true); $this->setRelatedData($this); $this->setExists(true); + $this->fireEvent('retrieved'); return $this; } diff --git a/src/QueriesRelationships.php b/src/QueriesRelationships.php index c94c7c2..67ebb5c 100644 --- a/src/QueriesRelationships.php +++ b/src/QueriesRelationships.php @@ -111,8 +111,11 @@ public function withExists($relation) /** * Adds an aggregate sub query for the given relation(s) to this query. * + * The aggregated column may be expressed inline as `relation.column` + * (e.g. `withSum('posts.amount')`); otherwise the `$column` fallback is used. + * * @param string|array $relation - * @param mixed $column + * @param mixed $column fallback column when a token carries none * @param string $function * * @return $this @@ -127,23 +130,26 @@ public function withAggregate($relation, $column, $function) $this->select = ["`{$this->_model->getTable()}`.*"]; } - if ($column !== '*') { - $column = $this->prepareColumnName($column); - } + [$relations, $columns] = $this->normalizeAggregateRelations((array) $relation, $column); - foreach ($this->_model->prepareRelation((array) $relation) as $relationName => $relationalQuery) { + foreach ($this->_model->prepareRelation($relations) as $relationName => $relationalQuery) { [$name, $alias] = $this->_model->prepareRelationName($relationName); if (\is_null($alias)) { $alias = strtolower($name . '_' . $function); } + $aggregateColumn = $columns[$relationName]; + $this->correlate($relationalQuery); if ($function === 'exists') { - $query = $relationalQuery->select($column)->prepare(); + $query = $relationalQuery->select($aggregateColumn)->prepare(); $this->selectRaw("exists({$query}) as `{$alias}`")->withCast([$alias => 'bool']); } else { - $query = $relationalQuery->selectRaw(sprintf('%s(%s)', $function, $column))->prepare(); + if ($aggregateColumn !== '*') { + $aggregateColumn = $relationalQuery->prepareColumnName($aggregateColumn); + } + $query = $relationalQuery->selectRaw(sprintf('%s(%s)', $function, $aggregateColumn))->prepare(); $this->selectRaw("({$query}) as `{$alias}`"); } } @@ -191,6 +197,67 @@ public function withWhereHas($relation, $callback = null) return $this; } + /** + * Splits relation tokens into the relation expression (kept for resolution, + * including any `as `) and the column to aggregate, preserving the + * int-keyed-string vs string-keyed-closure shapes that prepareRelation expects. + * + * @param array $relation + * @param mixed $defaultColumn + * + * @return array{0: array, 1: array} + */ + private function normalizeAggregateRelations(array $relation, $defaultColumn) + { + $relations = []; + $columns = []; + + foreach ($relation as $key => $value) { + $positional = \is_int($key) && \is_string($value); + [$relationExpr, $column] = $this->splitAggregateColumn($positional ? $value : (string) $key, $defaultColumn); + + $columns[$relationExpr] = $column; + if ($positional) { + $relations[] = $relationExpr; + } else { + $relations[$relationExpr] = $value; + } + } + + return [$relations, $columns]; + } + + /** + * Parses a `relation[.column][ as alias]` token. + * + * @param string $expr + * @param mixed $defaultColumn used when the token carries no `.column` + * + * @return array{0: string, 1: mixed} [relationExpr, column] + */ + private function splitAggregateColumn($expr, $defaultColumn) + { + $alias = ''; + $body = $expr; + if (preg_match('/^(.*?)\s+as\s+(.*)$/i', $expr, $matches)) { + $body = $matches[1]; + $alias = trim($matches[2]); + } + + if (strpos($body, '.') !== false) { + [$relation, $column] = explode('.', $body, 2); + $column = trim($column); + } else { + $relation = $body; + $column = $defaultColumn; + } + + $relation = trim($relation); + $relationExpr = $alias !== '' ? $relation . ' as ' . $alias : $relation; + + return [$relationExpr, $column]; + } + /** * Resolves relation names (and an optional constraint) into prepared * relational queries. diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index 73cf549..02433f6 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -971,6 +971,10 @@ public function destroy($ids = []) */ public function save() { + if ($this->_model->fireEvent('saving') === false) { + return false; + } + $columns = $this->prepareAttributeForSaveOrUpdate($this->_model->exists()); $pk = $this->_model->getPrimaryKey(); if ($this->_model->exists()) { @@ -989,11 +993,16 @@ public function save() $this->update = $columns; - return $this->exec() ? $this->_model : false; + if ($this->exec()) { + $this->_model->fireEvent('saved'); + + return $this->_model; + } + + return false; } $this->insert = $columns; - $this->_model->fireEvent('saving'); $this->exec(); if ($insertId = $this->lastInsertId()) { $this->_model->setAttribute($pk, $insertId); @@ -1012,7 +1021,7 @@ public function save() */ public function count() { - return $this->aggregate('COUNT', $this->_model->getPrimaryKey()); + return (int) $this->aggregate('COUNT', $this->_model->getPrimaryKey()); } public function max($column) @@ -1029,10 +1038,10 @@ public function aggregate($function, $column) { $query = $this->clone(); $query->select = []; - $query->selectRaw = []; + $query->selectRaw = ['columns' => [], 'bindings' => []]; $result = $query->selectRaw($function . '(' . $query->prepareColumnName($column) . ') as ' . $function)->exec(); - return \is_array($result) && !empty($result[0]->{$function}) ? $result[0]->{$function} : null; + return \is_array($result) && isset($result[0]->{$function}) ? $result[0]->{$function} : null; } public function delete() @@ -1755,8 +1764,6 @@ private function prepareUpdate() $sql .= ' SET '; $columnCount = \count($this->update); foreach ($this->update as $key => $column) { - // $sql .= $column . ' = ' . $this->getValueType($this->bindings[$key]); - if (\is_null($this->bindings[$key])) { $sql .= $column . ' = NULL'; unset($this->bindings[$key]); @@ -1781,23 +1788,20 @@ private function prepareUpdate() */ private function prepareDelete() { - if (property_exists($this->_model, 'soft_deletes') && $this->_model->soft_deletes) { - $this->update['deleted_at'] = $this->currentTimestamp(); - - return $this->prepareUpdate(); - } - - $sql = 'DELETE FROM ' . $this->table; - $whereClause = $this->getWhere($this); if (empty($whereClause)) { return ''; } - $sql .= $whereClause; + if (property_exists($this->_model, 'soft_deletes') && $this->_model->soft_deletes) { + $timestamp = $this->currentTimestamp(); + array_unshift($this->bindings, $timestamp); - return $sql; + return 'UPDATE ' . $this->table . ' SET deleted_at = ' . $this->getValueType($timestamp) . $whereClause; + } + + return 'DELETE FROM ' . $this->table . $whereClause; } /** From 9da5921db1fd975754155b9722765367d962aa32 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Mon, 29 Jun 2026 17:58:15 +0600 Subject: [PATCH 042/102] fix: use 7.4-compatible nullable types `callable|null` (Collection::first/last) and `array|null` (QueryBuilder::upsert) are PHP 8.0+ union syntax and fatal-parse on 7.4, which composer.json still supports (^7.4). Replace with the `?type` nullable form. Assisted-By: AI --- src/Collection.php | 4 ++-- src/QueryBuilder.php | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Collection.php b/src/Collection.php index afeb346..5540b52 100644 --- a/src/Collection.php +++ b/src/Collection.php @@ -50,7 +50,7 @@ public function pluck($key) }, $this->items)); } - public function first(callable|null $callback = null, $default = null) + public function first(?callable $callback = null, $default = null) { foreach ($this->items as $item) { if ($callback === null || $callback($item)) { @@ -61,7 +61,7 @@ public function first(callable|null $callback = null, $default = null) return $default; } - public function last(callable|null $callback = null, $default = null) + public function last(?callable $callback = null, $default = null) { return $this->reverse()->first($callback, $default); } diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index 02433f6..98bbb4c 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -1138,7 +1138,7 @@ public function when($value = null, ?callable $callback = null, ?callable $defau return $this; } - public function upsert(array $values, array|null $update = null) + public function upsert(array $values, ?array $update = null) { if (!\is_array(reset($values))) { $values = [$values]; From 8064f203dcd3602391c1a61b535cc1a3ffc1a2ed Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Mon, 29 Jun 2026 18:03:13 +0600 Subject: [PATCH 043/102] refactor: move internal traits into Concerns namespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relocate the three internal traits into `BitApps\WPDatabase\Concerns` (Relations, QueriesRelationships, HasEvents) to separate cross-cutting concerns from the public surface. Pure organization — no public class moved, no logic change. Relations/QueriesRelationships gain `use` imports for the QueryBuilder (and Model) types they reference; Model and QueryBuilder import the relocated traits. PSR-4 resolves the new path automatically. Assisted-By: AI --- src/{ => Concerns}/HasEvents.php | 2 +- src/{ => Concerns}/QueriesRelationships.php | 3 ++- src/{ => Concerns}/Relations.php | 4 +++- src/Model.php | 4 +++- src/QueryBuilder.php | 3 ++- 5 files changed, 11 insertions(+), 5 deletions(-) rename src/{ => Concerns}/HasEvents.php (98%) rename src/{ => Concerns}/QueriesRelationships.php (99%) rename src/{ => Concerns}/Relations.php (98%) diff --git a/src/HasEvents.php b/src/Concerns/HasEvents.php similarity index 98% rename from src/HasEvents.php rename to src/Concerns/HasEvents.php index 8c6a099..80cdf52 100644 --- a/src/HasEvents.php +++ b/src/Concerns/HasEvents.php @@ -4,7 +4,7 @@ * Class For Database Relations. */ -namespace BitApps\WPDatabase; +namespace BitApps\WPDatabase\Concerns; use Closure; diff --git a/src/QueriesRelationships.php b/src/Concerns/QueriesRelationships.php similarity index 99% rename from src/QueriesRelationships.php rename to src/Concerns/QueriesRelationships.php index 67ebb5c..5e6366a 100644 --- a/src/QueriesRelationships.php +++ b/src/Concerns/QueriesRelationships.php @@ -5,8 +5,9 @@ * relation aggregates. Kept separate from core query building (SRP). */ -namespace BitApps\WPDatabase; +namespace BitApps\WPDatabase\Concerns; +use BitApps\WPDatabase\QueryBuilder; use Closure; if (!\defined('ABSPATH')) { diff --git a/src/Relations.php b/src/Concerns/Relations.php similarity index 98% rename from src/Relations.php rename to src/Concerns/Relations.php index 4ab721a..a41f1d0 100644 --- a/src/Relations.php +++ b/src/Concerns/Relations.php @@ -4,8 +4,10 @@ * Class For Database Relations. */ -namespace BitApps\WPDatabase; +namespace BitApps\WPDatabase\Concerns; +use BitApps\WPDatabase\Model; +use BitApps\WPDatabase\QueryBuilder; use Closure; if (!\defined('ABSPATH')) { diff --git a/src/Model.php b/src/Model.php index 6679999..e32b324 100644 --- a/src/Model.php +++ b/src/Model.php @@ -8,8 +8,10 @@ use ArrayAccess; -use DateTime; +use BitApps\WPDatabase\Concerns\HasEvents; +use BitApps\WPDatabase\Concerns\Relations; +use DateTime; use JsonSerializable; use ReturnTypeWillChange; use RuntimeException; diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index 98bbb4c..6df50eb 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -2,8 +2,9 @@ namespace BitApps\WPDatabase; -use Closure; +use BitApps\WPDatabase\Concerns\QueriesRelationships; +use Closure; use DateTime; use DateTimeZone; use RuntimeException; From 3394839abad4e144c6daf299c84868ab4db4f7e2 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Mon, 29 Jun 2026 18:20:45 +0600 Subject: [PATCH 044/102] refactor: extract SELECT compilation into Query\Grammar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pull the SELECT-side SQL compilation (~16 methods: compileSelect, getWhere, getConditions, processConditions, getJoin/GroupBy/Having/OrderBy/From/Limit/ Offset, prepareColumnForWhere/ValueForWhere/OperatorForWhere, prepareRawSelect) out of the 1,889-line QueryBuilder into a stateless `BitApps\WPDatabase\Query\ Grammar` collaborator. The builder keeps state, fluent setters, execution and binding mutation; Grammar reads builder state via getters and appends bindings through the builder. `toSql()` now dispatches explicitly (no stringly `{'prepare'.$method}()`). Write-side compilation (insert/update/delete) and the setter-time/static-coupled helpers stay on the builder. Behavior unchanged — toSql output is byte-for-byte identical; adds Query\GrammarTest. Assisted-By: AI --- src/Query/Grammar.php | 341 +++++++++++++++++++++++++ src/QueryBuilder.php | 480 ++++++++++++------------------------ tests/Query/GrammarTest.php | 127 ++++++++++ 3 files changed, 628 insertions(+), 320 deletions(-) create mode 100644 src/Query/Grammar.php create mode 100644 tests/Query/GrammarTest.php diff --git a/src/Query/Grammar.php b/src/Query/Grammar.php new file mode 100644 index 0000000..4f44ef3 --- /dev/null +++ b/src/Query/Grammar.php @@ -0,0 +1,341 @@ +resetBindings(); + + $sql = 'SELECT ' . implode(',', $query->select); + $sql .= $this->prepareRawSelect($query); + $sql .= ' FROM ' . $query->getTable(); + $sql .= $this->getFrom($query); + $sql .= $this->getJoin($query); + $sql .= $this->getWhere($query); + $sql .= $this->getGroupBy($query); + $sql .= $this->getHaving($query); + $sql .= $this->getOrderBy($query); + $sql .= $this->getLimit($query); + $sql .= $this->getOffset($query); + + return trim($sql); + } + + /** + * Returns the processed WHERE clause (empty string when there is none). + * + * @return string + */ + public function getWhere(QueryBuilder $query) + { + $sql = $this->getConditions($query); + if (empty($sql)) { + return ''; + } + + return " WHERE {$sql}"; + } + + /** + * Returns the SQL for the JOIN clauses. + * + * @return string + */ + public function getJoin(QueryBuilder $query) + { + $sql = ''; + $joins = $query->getJoins(); + if (empty($joins)) { + return $sql; + } + + foreach ($joins as $join) { + $sql .= ' ' . $join['type'] . ' JOIN ' . $join['table'] + . ' ON ' . $this->processConditions($query, $join['on']); + } + + return $sql; + } + + /** + * Processes a list of conditions (where/having/join-on) into SQL. + * + * @param array $conditions + * @param null|string $type + * + * @return string + */ + public function processConditions(QueryBuilder $query, $conditions, $type = null) + { + $sql = ''; + if (\is_array($conditions) && \count($conditions) > 0) { + foreach ($conditions as $clause) { + if (isset($clause['bool'])) { + $sql .= ' ' . $clause['bool']; + } else { + $sql .= ' AND'; + } + + if (isset($clause['raw'])) { + $sql .= ' ' . $clause['raw']; + $query->addBindings($clause['bindings']); + + continue; + } + + if (isset($clause['query']) && !\is_null($type)) { + $clause['query']->resetBindings(); + $sql .= ' (' . $this->getConditions($clause['query'], $type) . ')'; + $query->addBindings($clause['query']->getBindings()); + + continue; + } + + $sql .= $this->prepareColumnForWhere($query, $clause); + $sql .= $this->prepareOperatorForWhere($clause); + $sql .= $this->prepareValueForWhere($query, $clause); + } + + $sql = $this->removeLeadingBool($sql); + } + + return $sql; + } + + /** + * Returns the processed conditions for the given clause list. + * + * @param string $type + * + * @return string + */ + public function getConditions(QueryBuilder $query, $type = 'where') + { + return $this->processConditions($query, $query->getClauseList($type), $type); + } + + /** + * Returns the SQL for the GROUP BY clause. + * + * @return string + */ + public function getGroupBy(QueryBuilder $query) + { + $groupBy = $query->getGroupByList(); + if (empty($groupBy)) { + return ''; + } + + return ' GROUP BY ' . implode(',', $groupBy); + } + + /** + * Returns the SQL for the HAVING clause. + * + * @return string + */ + public function getHaving(QueryBuilder $query) + { + $sql = $this->getConditions($query, 'having'); + if (empty($sql)) { + return ''; + } + + return " HAVING {$sql}"; + } + + /** + * Returns the SQL for the ORDER BY clause. + * + * @return string + */ + public function getOrderBy(QueryBuilder $query) + { + $sql = ''; + $orderBy = $query->getOrderByList(); + if (empty($orderBy)) { + return $sql; + } + + foreach ($orderBy as $order) { + if (isset($order['raw'])) { + $sql .= $order['raw'] . ', '; + $query->addBindings($order['bindings']); + } elseif (isset($order['column'])) { + $sql .= $order['column'] . ' ' . $order['direction'] . ', '; + } + } + + return ' ORDER BY ' . rtrim($sql, ', '); + } + + /** + * Returns the table alias fragment for the FROM clause. + * + * @return string|null + */ + public function getFrom(QueryBuilder $query) + { + $alias = $query->getFromAlias(); + + return isset($alias) ? " {$alias}" : null; + } + + /** + * Returns the LIMIT fragment for the query. + * + * @return string + */ + public function getLimit(QueryBuilder $query) + { + $limit = $query->getLimitValue(); + + return isset($limit) ? " LIMIT {$limit}" : ''; + } + + /** + * Returns the OFFSET fragment for the query. + * + * @return string + */ + public function getOffset(QueryBuilder $query) + { + $limit = $query->getLimitValue(); + $offset = $query->getOffsetValue(); + + return isset($limit) && isset($offset) ? " OFFSET {$offset}" : ''; + } + + /** + * Compiles the raw select columns and merges their bindings. + * + * @return string + */ + public function prepareRawSelect(QueryBuilder $query) + { + $sql = ''; + if (!empty($query->selectRaw['columns'])) { + $sql = \count($query->select) ? ', ' : ''; + $sql .= implode(', ', $query->selectRaw['columns']); + } + + if (!empty($query->selectRaw['bindings'])) { + $query->addBindings($query->selectRaw['bindings']); + } + + return $sql; + } + + /** + * Prepares the column part of a where clause. + * + * @param array $clause + * + * @return string|null + */ + public function prepareColumnForWhere(QueryBuilder $query, $clause) + { + if (isset($clause['column'])) { + return ' ' . $query->prepareColumnName($clause['column']); + } + } + + /** + * Prepares the operator part of a where clause. + * + * @param array $clause + * + * @return string + */ + public function prepareOperatorForWhere($clause) + { + $sql = ''; + if (!isset($clause['column'])) { + return $sql; + } + + if (isset($clause['operator'])) { + $sql .= ' ' . $clause['operator']; + } elseif (\is_array($clause['value'])) { + $sql .= ' IN '; + } elseif (\is_null($clause['value'])) { + $sql = ' IS NULL'; + } else { + $sql .= ' = '; + } + + return $sql; + } + + /** + * Prepares the value part of a where clause, registering bindings. + * + * @param array $clause + * + * @return string + */ + public function prepareValueForWhere(QueryBuilder $query, $clause) + { + $sql = ''; + if (isset($clause['secondColumn'])) { + return ' ' . $clause['secondColumn']; + } + + if (!isset($clause['value'])) { + return $sql; + } + + if (\is_array($clause['value'])) { + $sql .= ' ('; + foreach ($clause['value'] as $value) { + $sql .= $query->getValueType($value) . ','; + $query->addBindings($value); + } + + $sql = rtrim($sql, ',') . ')'; + } elseif (isset($clause['operator']) && strpos($clause['operator'], 'IS') !== false) { + $sql .= ' ' . $clause['value']; + } elseif (isset($clause['operator']) && strtoupper($clause['operator'] === 'LIKE')) { + $sql .= ' %s'; + $query->addBindings($clause['value']); + } elseif (!\is_null($clause['value'])) { + $sql .= ' ' . $query->getValueType($clause['value']); + $query->addBindings($clause['value']); + } + + return $sql; + } + + /** + * Removes a single leading AND/OR boolean from a condition string. + * + * @param string $sql + * + * @return string + */ + public function removeLeadingBool($sql) + { + return preg_replace('/and |or /i', '', $sql, 1); + } +} diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index 6df50eb..7f14540 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -3,6 +3,7 @@ namespace BitApps\WPDatabase; use BitApps\WPDatabase\Concerns\QueriesRelationships; +use BitApps\WPDatabase\Query\Grammar; use Closure; use DateTime; @@ -74,6 +75,8 @@ class QueryBuilder private $_method; + private $_grammar; + /** * Constructs QueryBuilder * @@ -160,6 +163,16 @@ public function newQuery() return new QueryBuilder($this->_model); } + /** + * Returns the SQL grammar used to compile select statements. + * + * @return Grammar + */ + public function grammar() + { + return $this->_grammar ??= new Grammar(); + } + /** * Add bindings for this query * @@ -190,6 +203,118 @@ public function getBindings() return $this->bindings; } + /** + * Resets the bindings collected for this query. + * + * @return void + */ + public function resetBindings() + { + $this->bindings = []; + } + + /** + * Returns the (prefixed) table name for this query. + * + * @return string + */ + public function getTable() + { + return $this->table; + } + + /** + * Returns the query method (select/insert/update/delete/raw). + * + * @return string|null + */ + public function getMethod() + { + return $this->_method; + } + + /** + * Returns the raw SQL set for a raw query. + * + * @return string + */ + public function getRawSql() + { + return $this->raw; + } + + /** + * Returns the clause list (where/having) for the given type. + * + * @param string $type + * + * @return array + */ + public function getClauseList($type) + { + return $this->{$type}; + } + + /** + * Returns the join definitions for this query. + * + * @return array + */ + public function getJoins() + { + return $this->joins; + } + + /** + * Returns the group by columns for this query. + * + * @return array + */ + public function getGroupByList() + { + return $this->groupBy; + } + + /** + * Returns the order by definitions for this query. + * + * @return array + */ + public function getOrderByList() + { + return $this->orderBy; + } + + /** + * Returns the limit value for this query. + * + * @return int|string|null + */ + public function getLimitValue() + { + return $this->limit; + } + + /** + * Returns the offset value for this query. + * + * @return int|string|null + */ + public function getOffsetValue() + { + return $this->offset; + } + + /** + * Returns the table alias set via from(). + * + * @return string|null + */ + public function getFromAlias() + { + return $this->_from; + } + /** * Get all rows * @@ -342,46 +467,6 @@ public function findOne($attributes) return $this->first(); } - /** - * Get processed conditions - * - * @param QueryBuilder $query - * @param string $type - * - * @return string|string[]|null - */ - public function getConditions(QueryBuilder $query, $type = 'where') - { - return $this->processConditions($query->{$type}, $type); - } - - /** - * Prepare operator for where clause - * - * @param array $clause - * - * @return void - */ - public function prepareOperatorForWhere($clause) - { - $sql = ''; - if (!isset($clause['column'])) { - return $sql; - } - - if (isset($clause['operator'])) { - $sql .= ' ' . $clause['operator']; - } elseif (\is_array($clause['value'])) { - $sql .= ' IN '; - } elseif (\is_null($clause['value'])) { - $sql = ' IS NULL'; - } else { - $sql .= ' = '; - } - - return $sql; - } - /** * Set where clause * @@ -742,30 +827,6 @@ public function orOn($firstColumn, $operator = null, $secondColumn = null) return $this->on($firstColumn, $operator, $secondColumn, 'OR'); } - /** - * Returns order by clause sql - * - * @return string - */ - public function getOrderBy() - { - $sql = ''; - if (empty($this->orderBy)) { - return $sql; - } - - foreach ($this->orderBy as $order) { - if (isset($order['raw'])) { - $sql .= $order['raw'] . ', '; - $this->addBindings($order['bindings']); - } elseif (isset($order['column'])) { - $sql .= $order['column'] . ' ' . $order['direction'] . ', '; - } - } - - return ' ORDER BY ' . rtrim($sql, ', '); - } - /** * Sets order by * @@ -900,16 +961,6 @@ public function skip($count) return $this; } - /** - * Returns processed offset for query - * - * @return string|null - */ - public function getOffset() - { - return isset($this->limit) && isset($this->offset) ? " OFFSET {$this->offset}" : ''; - } - /** * Run insert query for model * @@ -1117,10 +1168,31 @@ public function prepare($sql = null) public function toSql() { if (isset($this->_method)) { - $sql = $this->{'prepare' . $this->_method}(); + switch ($this->_method) { + case self::SELECT: + $sql = $this->grammar()->compileSelect($this); + + break; + case self::INSERT: + $sql = $this->prepareInsert(); + + break; + case self::UPDATE: + $sql = $this->prepareUpdate(); + + break; + case self::DELETE: + $sql = $this->prepareDelete(); + + break; + case self::RAW: + $sql = $this->prepareRaw(); + + break; + } } elseif (!empty($this->select) || !empty($this->selectRaw)) { $this->_method = self::SELECT; - $sql = $this->prepareSelect(); + $sql = $this->grammar()->compileSelect($this); } return $sql; @@ -1206,48 +1278,23 @@ function ($value) { } /** - * Process conditions + * Returns types * - * @param array $conditions - * @param string $type + * @param mixed $value * * @return string */ - protected function processConditions($conditions, $type = null) + public function getValueType($value) { - $sql = ''; - if (\is_array($conditions) && \count($conditions) > 0) { - foreach ($conditions as $clause) { - if (isset($clause['bool'])) { - $sql .= ' ' . $clause['bool']; - } else { - $sql .= ' AND'; - } - - if (isset($clause['raw'])) { - $sql .= ' ' . $clause['raw']; - $this->addBindings($clause['bindings']); - - continue; - } - - if (isset($clause['query']) && !\is_null($type)) { - $clause['query']->bindings = []; - $sql .= ' (' . $clause['query']->getConditions($clause['query'], $type) . ')'; - $this->addBindings($clause['query']->getBindings()); - - continue; - } - - $sql .= $this->prepareColumnForWhere($clause); - $sql .= $this->prepareOperatorForWhere($clause); - $sql .= $this->prepareValueForWhere($clause, $this); - } + $placeHolder = '%s'; - $sql = $this->removeLeadingBool($sql); + if (\gettype($value) == 'integer') { + $placeHolder = '%d'; + } elseif (\gettype($value) == 'double') { + $placeHolder = '%f'; } - return $sql; + return $placeHolder; } /** @@ -1302,33 +1349,6 @@ protected function prepareConditional($params, $bool = 'AND', $type = 'where') return $conditions; } - /** - * Removes leading and | or - * - * @param string $sql - * - * @return string - */ - protected function removeLeadingBool($sql) - { - return preg_replace('/and |or /i', '', $sql, 1); - } - - /** - * Returns processed sql for where clause - * - * @return string - */ - protected function getWhere() - { - $sql = $this->getConditions($this); - if (empty($sql)) { - return ''; - } - - return " WHERE {$sql}"; - } - /** * Prepares where conditions * @@ -1345,20 +1365,6 @@ protected function prepareWhere($params, $bool = 'AND') } } - /** - * Returns sql for group by clause - * - * @return string - */ - protected function getGroupBy() - { - if (empty($this->groupBy)) { - return ''; - } - - return ' GROUP BY ' . implode(',', $this->groupBy); - } - /** * Prepare having * @@ -1377,40 +1383,6 @@ protected function prepareHaving($params, $bool = 'AND') return $this; } - /** - * Return sql for having clause - * - * @return string - */ - protected function getHaving() - { - $sql = $this->getConditions($this, 'having'); - if (empty($sql)) { - return ''; - } - - return " HAVING {$sql}"; - } - - /** - * Returns sql for join - * - * @return string - */ - protected function getJoin() - { - $sql = ''; - if (empty($this->joins)) { - return $sql; - } - - foreach ($this->joins as $join) { - $sql .= ' ' . $join['type'] . ' JOIN ' . $join['table'] . ' ON ' . $this->processConditions($join['on']); - } - - return $sql; - } - /** * Prepares on * @@ -1434,26 +1406,6 @@ protected function prepareOn($table, $column, $operator, $secondColumn, $bool = return compact('column', 'operator', 'secondColumn', 'bool'); } - /** - * Returns types - * - * @param mixed $value - * - * @return string - */ - protected function getValueType($value) - { - $placeHolder = '%s'; - - if (\gettype($value) == 'integer') { - $placeHolder = '%d'; - } elseif (\gettype($value) == 'double') { - $placeHolder = '%f'; - } - - return $placeHolder; - } - /** * Helper function, to get current timestamp * @@ -1489,21 +1441,6 @@ protected function getTimeZone() return $timezoneString; } - private function prepareRawSelect() - { - $query = ''; - if (!empty($this->selectRaw['columns'])) { - $query = \count($this->select) ? ', ' : ''; - $query .= implode(', ', $this->selectRaw['columns']); - } - - if (!empty($this->selectRaw['bindings'])) { - $this->bindings = array_merge($this->bindings, $this->selectRaw['bindings']); - } - - return $query; - } - /** * Run bulk insert query * @@ -1579,80 +1516,6 @@ function ($value) { return false; } - /** - * Table alias for select query - * - * @return string - */ - private function getFrom() - { - return isset($this->_from) ? " {$this->_from}" : null; - } - - /** - * Prepare column for where clause - * - * @param array $clause - * - * @return void - */ - private function prepareColumnForWhere($clause) - { - if (isset($clause['column'])) { - return ' ' . $this->prepareColumnName($clause['column']); - } - } - - /** - * Prepare value for where clause - * - * @param array $clause - * @param self $query - * - * @return string - */ - private function prepareValueForWhere($clause, self $query) - { - $sql = ''; - if (isset($clause['secondColumn'])) { - return ' ' . $clause['secondColumn']; - } - - if (!isset($clause['value'])) { - return $sql; - } - - if (\is_array($clause['value'])) { - $sql .= ' ('; - foreach ($clause['value'] as $value) { - $sql .= $this->getValueType($value) . ','; - $query->addBindings($value); - } - - $sql = rtrim($sql, ',') . ')'; - } elseif (isset($clause['operator']) && strpos($clause['operator'], 'IS') !== false) { - $sql .= ' ' . $clause['value']; - } elseif (isset($clause['operator']) && strtoupper($clause['operator'] === 'LIKE')) { - $sql .= ' %s'; - $query->addBindings($clause['value']); - } elseif (!\is_null($clause['value'])) { - $sql .= ' ' . $query->getValueType($clause['value']); - $query->addBindings($clause['value']); - } - - return $sql; - } - - /** - * Returns limit part for query - * - * @return string|null - */ - private function getLimit() - { - return isset($this->limit) ? " LIMIT {$this->limit}" : ''; - } - /** * Prepares columns and value * @@ -1700,29 +1563,6 @@ private function prepareAttributeForSaveOrUpdate($isUpdate = false) return $columnsToPrepare; } - /** - * Prepares select statement - * - * @return string - */ - private function prepareSelect() - { - $this->bindings = []; - $sql = 'SELECT ' . implode(',', $this->select); - $sql .= $this->prepareRawSelect(); - $sql .= ' FROM ' . $this->table; - $sql .= $this->getFrom(); - $sql .= $this->getJoin(); - $sql .= $this->getWhere($this); - $sql .= $this->getGroupBy(); - $sql .= $this->getHaving(); - $sql .= $this->getOrderBy(); - $sql .= $this->getLimit(); - $sql .= $this->getOffset(); - - return trim($sql); - } - /** * Prepares insert statement * @@ -1761,7 +1601,7 @@ function ($value, $key) { private function prepareUpdate() { $sql = 'UPDATE ' . $this->table; - $sql .= $this->getJoin(); + $sql .= $this->grammar()->getJoin($this); $sql .= ' SET '; $columnCount = \count($this->update); foreach ($this->update as $key => $column) { @@ -1777,7 +1617,7 @@ private function prepareUpdate() } } - $sql .= $this->getWhere($this); + $sql .= $this->grammar()->getWhere($this); return $sql; } @@ -1789,7 +1629,7 @@ private function prepareUpdate() */ private function prepareDelete() { - $whereClause = $this->getWhere($this); + $whereClause = $this->grammar()->getWhere($this); if (empty($whereClause)) { return ''; diff --git a/tests/Query/GrammarTest.php b/tests/Query/GrammarTest.php new file mode 100644 index 0000000..dc34f38 --- /dev/null +++ b/tests/Query/GrammarTest.php @@ -0,0 +1,127 @@ +assertSame( + 'SELECT `wp_users`.* FROM wp_users', + User::select('*')->toSql() + ); + } + + public function testSelectSpecificColumns(): void + { + $this->assertSame( + 'SELECT `wp_users`.`id`,`wp_users`.`user_login` FROM wp_users', + (new User())->select('id', 'user_login')->toSql() + ); + } + + public function testWhereEquals(): void + { + $this->assertSame( + 'SELECT FROM wp_users WHERE `wp_users`.`id` = %d', + (new User())->where('id', 5)->toSql() + ); + } + + public function testWhereWithOperator(): void + { + $this->assertSame( + 'SELECT FROM wp_users WHERE `wp_users`.`id` > %d', + (new User())->where('id', '>', 0)->toSql() + ); + } + + public function testOrWhere(): void + { + $this->assertSame( + 'SELECT FROM wp_users WHERE `wp_users`.`a` = %d OR `wp_users`.`b` = %d', + (new User())->where('a', 1)->orWhere('b', 2)->toSql() + ); + } + + public function testNestedClosureWhereProducesParenthesizedGroup(): void + { + $sql = User::where('a', 1)->where(function ($q) { + $q->where('b', 2)->orWhere('c', 3); + })->toSql(); + + $this->assertSame( + 'SELECT FROM wp_users WHERE `wp_users`.`a` = %d AND ' + . '( `wp_users`.`b` = %d OR `wp_users`.`c` = %d)', + $sql + ); + + $this->assertStringContainsString('( `wp_users`.`b` = %d OR `wp_users`.`c` = %d)', $sql); + } + + public function testJoin(): void + { + $this->assertSame( + 'SELECT FROM wp_users INNER JOIN wp_wp_posts ON `wp_users`.`user_id` = id', + (new User())->join('posts', 'user_id', '=', 'id')->toSql() + ); + } + + public function testGroupBy(): void + { + $this->assertSame( + 'SELECT FROM wp_users GROUP BY status', + (new User())->groupBy('status')->toSql() + ); + } + + public function testHaving(): void + { + $this->assertSame( + 'SELECT FROM wp_users GROUP BY status HAVING `wp_users`.`id` > %d', + (new User())->groupBy('status')->having('id', '>', 1)->toSql() + ); + } + + public function testOrderByDesc(): void + { + $this->assertSame( + 'SELECT FROM wp_users ORDER BY id DESC', + (new User())->orderBy('id')->desc()->toSql() + ); + } + + public function testLimitAndOffset(): void + { + $this->assertSame( + 'SELECT FROM wp_users LIMIT 10 OFFSET 20', + (new User())->take(10)->skip(20)->toSql() + ); + } + + public function testLimitWithoutOffsetOmitsOffsetClause(): void + { + $this->assertSame( + 'SELECT FROM wp_users LIMIT 5', + (new User())->take(5)->toSql() + ); + } + + public function testCompileSelectReturnsString(): void + { + $query = (new User())->select('*'); + + $this->assertIsString($query->grammar()->compileSelect($query)); + $this->assertInstanceOf(QueryBuilder::class, $query); + } +} From edfa9bb39f2ad9b27b60e8dd5b6f1c4211da6ae8 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Mon, 29 Jun 2026 18:26:23 +0600 Subject: [PATCH 045/102] refactor: tighten Grammar surface and drop dead getters Post-extraction cleanup (no behavior change): - Grammar: only compileSelect/getWhere/getJoin are called cross-object; make the other 13 compilation helpers private. Reword the class docblock to state it drives the builder's binding lifecycle (it is not side-effect-free). - QueryBuilder: drop the unused getMethod() and getRawSql() (the latter dup'd prepareRaw()); guard getClauseList() to where/having only instead of exposing every property via $this->{$type}; initialise $sql in toSql(). Assisted-By: AI --- src/Query/Grammar.php | 35 ++++++++++++++++++----------------- src/QueryBuilder.php | 23 ++--------------------- 2 files changed, 20 insertions(+), 38 deletions(-) diff --git a/src/Query/Grammar.php b/src/Query/Grammar.php index 4f44ef3..900c8b3 100644 --- a/src/Query/Grammar.php +++ b/src/Query/Grammar.php @@ -11,10 +11,11 @@ /** * Compiles the SELECT side of a {@see QueryBuilder} into an SQL string. * - * Stateless collaborator: every method receives the QueryBuilder first, reads - * its state through public getters and pushes any placeholders back onto the - * builder via {@see QueryBuilder::addBindings()}. The builder owns the bindings; - * the grammar only produces the SQL. + * Holds no state of its own: every method receives the QueryBuilder first and + * reads its state through getters. It does drive the builder's binding lifecycle + * while compiling — resetting (including nested builders) and repopulating + * bindings in placeholder order via {@see QueryBuilder::addBindings()} — so the + * resulting binding array lines up with the emitted placeholders. */ class Grammar { @@ -86,7 +87,7 @@ public function getJoin(QueryBuilder $query) * * @return string */ - public function processConditions(QueryBuilder $query, $conditions, $type = null) + private function processConditions(QueryBuilder $query, $conditions, $type = null) { $sql = ''; if (\is_array($conditions) && \count($conditions) > 0) { @@ -130,7 +131,7 @@ public function processConditions(QueryBuilder $query, $conditions, $type = null * * @return string */ - public function getConditions(QueryBuilder $query, $type = 'where') + private function getConditions(QueryBuilder $query, $type = 'where') { return $this->processConditions($query, $query->getClauseList($type), $type); } @@ -140,7 +141,7 @@ public function getConditions(QueryBuilder $query, $type = 'where') * * @return string */ - public function getGroupBy(QueryBuilder $query) + private function getGroupBy(QueryBuilder $query) { $groupBy = $query->getGroupByList(); if (empty($groupBy)) { @@ -155,7 +156,7 @@ public function getGroupBy(QueryBuilder $query) * * @return string */ - public function getHaving(QueryBuilder $query) + private function getHaving(QueryBuilder $query) { $sql = $this->getConditions($query, 'having'); if (empty($sql)) { @@ -170,7 +171,7 @@ public function getHaving(QueryBuilder $query) * * @return string */ - public function getOrderBy(QueryBuilder $query) + private function getOrderBy(QueryBuilder $query) { $sql = ''; $orderBy = $query->getOrderByList(); @@ -195,7 +196,7 @@ public function getOrderBy(QueryBuilder $query) * * @return string|null */ - public function getFrom(QueryBuilder $query) + private function getFrom(QueryBuilder $query) { $alias = $query->getFromAlias(); @@ -207,7 +208,7 @@ public function getFrom(QueryBuilder $query) * * @return string */ - public function getLimit(QueryBuilder $query) + private function getLimit(QueryBuilder $query) { $limit = $query->getLimitValue(); @@ -219,7 +220,7 @@ public function getLimit(QueryBuilder $query) * * @return string */ - public function getOffset(QueryBuilder $query) + private function getOffset(QueryBuilder $query) { $limit = $query->getLimitValue(); $offset = $query->getOffsetValue(); @@ -232,7 +233,7 @@ public function getOffset(QueryBuilder $query) * * @return string */ - public function prepareRawSelect(QueryBuilder $query) + private function prepareRawSelect(QueryBuilder $query) { $sql = ''; if (!empty($query->selectRaw['columns'])) { @@ -254,7 +255,7 @@ public function prepareRawSelect(QueryBuilder $query) * * @return string|null */ - public function prepareColumnForWhere(QueryBuilder $query, $clause) + private function prepareColumnForWhere(QueryBuilder $query, $clause) { if (isset($clause['column'])) { return ' ' . $query->prepareColumnName($clause['column']); @@ -268,7 +269,7 @@ public function prepareColumnForWhere(QueryBuilder $query, $clause) * * @return string */ - public function prepareOperatorForWhere($clause) + private function prepareOperatorForWhere($clause) { $sql = ''; if (!isset($clause['column'])) { @@ -295,7 +296,7 @@ public function prepareOperatorForWhere($clause) * * @return string */ - public function prepareValueForWhere(QueryBuilder $query, $clause) + private function prepareValueForWhere(QueryBuilder $query, $clause) { $sql = ''; if (isset($clause['secondColumn'])) { @@ -334,7 +335,7 @@ public function prepareValueForWhere(QueryBuilder $query, $clause) * * @return string */ - public function removeLeadingBool($sql) + private function removeLeadingBool($sql) { return preg_replace('/and |or /i', '', $sql, 1); } diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index 7f14540..0a38fa9 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -223,26 +223,6 @@ public function getTable() return $this->table; } - /** - * Returns the query method (select/insert/update/delete/raw). - * - * @return string|null - */ - public function getMethod() - { - return $this->_method; - } - - /** - * Returns the raw SQL set for a raw query. - * - * @return string - */ - public function getRawSql() - { - return $this->raw; - } - /** * Returns the clause list (where/having) for the given type. * @@ -252,7 +232,7 @@ public function getRawSql() */ public function getClauseList($type) { - return $this->{$type}; + return $type === 'having' ? $this->having : $this->where; } /** @@ -1167,6 +1147,7 @@ public function prepare($sql = null) */ public function toSql() { + $sql = ''; if (isset($this->_method)) { switch ($this->_method) { case self::SELECT: From c3ea48039911821f87c6a1bc93c7cd7f604f88aa Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Mon, 29 Jun 2026 18:35:06 +0600 Subject: [PATCH 046/102] test: cover joins, subqueries, when(), constrained eager load and update Characterization tests asserting both compiled SQL and binding-array order (the risk surface of the Query\Grammar extraction): inner/left joins, nested closure grouping with flattened binding order, whereHas correlated exists sub-query (plain + constrained), selectRaw-before-where binding order, when() true/default branches, with() closure-constrained eager load, and bulk update. Assisted-By: AI --- tests/QueryFeaturesTest.php | 148 ++++++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 tests/QueryFeaturesTest.php diff --git a/tests/QueryFeaturesTest.php b/tests/QueryFeaturesTest.php new file mode 100644 index 0000000..9cf7241 --- /dev/null +++ b/tests/QueryFeaturesTest.php @@ -0,0 +1,148 @@ +join('posts', 'posts.user_id', '=', 'users.id')->toSql(); + + $this->assertStringContainsString('INNER JOIN', $sql); + $this->assertStringContainsString('posts.user_id', $sql); + $this->assertStringContainsString('users.id', $sql); + } + + public function testLeftJoinThenWhereKeepsBindingsInOrder(): void + { + $qb = (new User())->leftJoin('posts', 'posts.user_id', '=', 'users.id')->where('users.id', '>', 5); + + $sql = $qb->toSql(); + + $this->assertStringContainsString('LEFT JOIN', $sql); + $this->assertStringContainsString('WHERE', $sql); + $this->assertSame([5], $qb->getBindings(), 'join carries no binding; only the where value'); + } + + // --- Sub-queries --------------------------------------------------------- + + public function testNestedClosureGroupsAndFlattensBindingsInOrder(): void + { + $qb = User::where('a', 1)->where(function ($q) { + $q->where('b', 2)->orWhere('c', 3); + }); + + $sql = $qb->toSql(); + + $this->assertStringContainsString('(', $sql); + $this->assertStringContainsString('OR', $sql); + $this->assertSame([1, 2, 3], $qb->getBindings(), 'outer then nested bindings, in placeholder order'); + } + + public function testWhereHasEmitsCorrelatedExistsSubquery(): void + { + $sql = User::whereHas('posts')->toSql(); + + $this->assertStringContainsString('exists(', $sql); + $this->assertStringContainsString('FROM wp_posts', $sql); + $this->assertStringContainsString('`wp_users`.`id`=`wp_posts`.`user_id`', $sql); + } + + public function testWhereHasAppliesConstraintInsideSubquery(): void + { + $sql = User::whereHas('posts', function ($q) { + $q->where('published', 1); + })->toSql(); + + $this->assertStringContainsString('exists(', $sql); + $this->assertStringContainsString('published', $sql); + } + + public function testSelectRawBindingsPrecedeWhereBindings(): void + { + $qb = User::query()->selectRaw('%s as label', ['L'])->where('id', 5); + + $qb->toSql(); + + $this->assertSame(['L', 5], $qb->getBindings(), 'selectRaw bindings compile before where bindings'); + } + + // --- when() -------------------------------------------------------------- + + public function testWhenTrueAppliesCallback(): void + { + $qb = User::query()->when(true, function ($q) { + $q->where('active', 1); + }); + + $sql = $qb->toSql(); + + $this->assertStringContainsString('active', $sql); + $this->assertSame([1], $qb->getBindings()); + } + + public function testWhenFalseRunsDefaultBranch(): void + { + $qb = User::query()->when( + false, + function ($q) { + $q->where('active', 1); + }, + function ($q) { + $q->where('active', 0); + } + ); + + $qb->toSql(); + + $this->assertSame([0], $qb->getBindings(), 'default branch applied when value is falsey'); + } + + // --- Constrained eager loading (relation callable $query) ---------------- + + public function testWithCallableConstrainsTheEagerLoadQuery(): void + { + $builder = User::with('posts', function ($q) { + $q->where('status', 'published'); + }); + $relation = $builder->getModel()->getRelations()['posts']; + + $this->assertStringContainsString('status', $relation->toSql()); + $this->assertSame(['published'], $relation->getBindings()); + } + + // --- Update path --------------------------------------------------------- + + public function testBulkUpdateExecutesUpdateStatement(): void + { + User::where('id', 1)->update(['status' => 'archived']); + + $sql = $GLOBALS['wpdb']->last_query; + + $this->assertStringStartsWith('UPDATE wp_users', $sql); + $this->assertMatchesRegularExpression('/SET\s+status\s*=/i', $sql); + $this->assertStringContainsString('WHERE', $sql); + } +} From 377710129d52237d655e525c29d4d0ef5f695c50 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Mon, 29 Jun 2026 19:21:23 +0600 Subject: [PATCH 047/102] fix: correct LIKE operator check in Grammar `strtoupper($clause['operator'] === 'LIKE')` uppercased a boolean instead of the operator, so the LIKE branch only matched the exact string 'LIKE' and never a lowercase/mixed-case operator. Apply strtoupper() to the operator, then compare. Behavior is unchanged for the realistic case (a string LIKE value is bound as %s by the fall-through branch either way); this fixes the intent and a contrived numeric-operand edge. Reported by Gemini Code Assist on the PR. Assisted-By: AI --- src/Query/Grammar.php | 2 +- tests/QueryFeaturesTest.php | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/Query/Grammar.php b/src/Query/Grammar.php index 900c8b3..2fbbe0a 100644 --- a/src/Query/Grammar.php +++ b/src/Query/Grammar.php @@ -317,7 +317,7 @@ private function prepareValueForWhere(QueryBuilder $query, $clause) $sql = rtrim($sql, ',') . ')'; } elseif (isset($clause['operator']) && strpos($clause['operator'], 'IS') !== false) { $sql .= ' ' . $clause['value']; - } elseif (isset($clause['operator']) && strtoupper($clause['operator'] === 'LIKE')) { + } elseif (isset($clause['operator']) && strtoupper($clause['operator']) === 'LIKE') { $sql .= ' %s'; $query->addBindings($clause['value']); } elseif (!\is_null($clause['value'])) { diff --git a/tests/QueryFeaturesTest.php b/tests/QueryFeaturesTest.php index 9cf7241..d66d6a8 100644 --- a/tests/QueryFeaturesTest.php +++ b/tests/QueryFeaturesTest.php @@ -145,4 +145,16 @@ public function testBulkUpdateExecutesUpdateStatement(): void $this->assertMatchesRegularExpression('/SET\s+status\s*=/i', $sql); $this->assertStringContainsString('WHERE', $sql); } + + // --- LIKE operator (case-insensitive) ------------------------------------ + + public function testLikeOperatorBindsValue(): void + { + $qb = User::where('name', 'like', '%ada%'); + + $sql = $qb->toSql(); + + $this->assertMatchesRegularExpression('/like\s+%s/i', $sql); + $this->assertSame(['%ada%'], $qb->getBindings(), 'LIKE value must be bound, not concatenated'); + } } From c9e95d8a79fb5bef83c592279a506439a6ae1dcf Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Mon, 29 Jun 2026 19:28:56 +0600 Subject: [PATCH 048/102] fix: address Gemini review findings (upsert, casts, pluck, withCast) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - upsert: sort() the column-name list (was ksort() — a no-op on the numeric list) so columns line up with the alphabetically-ksorted row values; use in_array() instead of array_key_exists() to detect created_at in the update list (the latter checked numeric keys, so the created_at→updated_at rewrite on duplicate key never fired). - Model: add castToBoolean() (the documented 'boolean' cast dispatched to a missing method and was silently ignored; only 'bool' worked). - Collection::pluck(): resolve dynamic/accessor attributes via Model::getAttribute() ($item->{$key} ?? null short-circuits __isset and returns null for unloaded relations/accessors). - QueryBuilder::withCast(): add a chainable builder-level method (forwarding to the model returned the Model and broke fluent chaining). Reported by Gemini Code Assist on the PR. Adds regression tests + CastModel/ AccessorModel fixtures. Assisted-By: AI --- src/Collection.php | 20 ++++++--- src/Model.php | 5 +++ src/QueryBuilder.php | 18 +++++++- tests/Fixtures/AccessorModel.php | 19 ++++++++ tests/Fixtures/CastModel.php | 16 +++++++ tests/GeminiReviewFixesTest.php | 74 ++++++++++++++++++++++++++++++++ tests/bootstrap.php | 2 + 7 files changed, 146 insertions(+), 8 deletions(-) create mode 100644 tests/Fixtures/AccessorModel.php create mode 100644 tests/Fixtures/CastModel.php create mode 100644 tests/GeminiReviewFixesTest.php diff --git a/src/Collection.php b/src/Collection.php index 5540b52..913bac1 100644 --- a/src/Collection.php +++ b/src/Collection.php @@ -3,9 +3,9 @@ namespace BitApps\WPDatabase; use ArrayAccess; +use ArrayIterator; use Countable; use IteratorAggregate; -use ArrayIterator; use JsonSerializable; use ReturnTypeWillChange; @@ -20,7 +20,7 @@ public function __construct(array $items = []) public static function make($items = []) { - return new static(is_array($items) ? $items : [$items]); + return new static(\is_array($items) ? $items : [$items]); } public function all() @@ -46,7 +46,15 @@ public function reduce(callable $callback, $initial = null) public function pluck($key) { return new static(array_map(function ($item) use ($key) { - return is_array($item) ? $item[$key] ?? null : (is_object($item) ? $item->{$key} ?? null : null); + if (\is_array($item)) { + return $item[$key] ?? null; + } + + if ($item instanceof Model) { + return $item->getAttribute($key); + } + + return \is_object($item) ? $item->{$key} ?? null : null; }, $this->items)); } @@ -74,7 +82,7 @@ public function reverse() public function toArray() { return array_map(function ($value) { - return is_object($value) && method_exists($value, 'toArray') ? $value->toArray() : $value; + return \is_object($value) && method_exists($value, 'toArray') ? $value->toArray() : $value; }, $this->items); } @@ -86,7 +94,7 @@ public function jsonSerialize():array #[ReturnTypeWillChange] public function count() { - return count($this->items); + return \count($this->items); } #[ReturnTypeWillChange] @@ -122,4 +130,4 @@ public function offsetUnset($offset) { unset($this->items[$offset]); } -} \ No newline at end of file +} diff --git a/src/Model.php b/src/Model.php index e32b324..012e1da 100644 --- a/src/Model.php +++ b/src/Model.php @@ -574,6 +574,11 @@ private function castToBool($value) return \boolval($value); } + private function castToBoolean($value) + { + return $this->castToBool($value); + } + private function castToDate($value) { return DateTime::createFromFormat('Y-m-d H:i:s', $value); diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index 0a38fa9..43fde14 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -115,6 +115,20 @@ public function clone() return clone $this; } + /** + * Adds casts to the bound model at runtime, keeping the builder chainable. + * + * @param array $casts + * + * @return $this + */ + public function withCast(array $casts) + { + $this->_model->withCast($casts); + + return $this; + } + /** * Sets alias for table * @@ -1204,7 +1218,7 @@ public function upsert(array $values, ?array $update = null) $this->bindings = []; $columns = array_keys($values[0]); - ksort($columns); + sort($columns); $createdAt = property_exists($this->_model, 'timestamps') && $this->_model->timestamps && !\in_array('created_at', $columns); if ($createdAt) { $columns[] = 'created_at'; @@ -1241,7 +1255,7 @@ function ($value) { $sql .= empty($insertAbleValues) ? ' default values' : ' ' . implode(',', $insertAbleValues); $sql .= ' ON DUPLICATE KEY UPDATE '; - if (\array_key_exists('created_at', $update)) { + if (\in_array('created_at', $update, true)) { $update = array_diff($update, ['created_at']); $update[] = 'updated_at'; } diff --git a/tests/Fixtures/AccessorModel.php b/tests/Fixtures/AccessorModel.php new file mode 100644 index 0000000..96a094d --- /dev/null +++ b/tests/Fixtures/AccessorModel.php @@ -0,0 +1,19 @@ + 'boolean']; +} diff --git a/tests/GeminiReviewFixesTest.php b/tests/GeminiReviewFixesTest.php new file mode 100644 index 0000000..55c5929 --- /dev/null +++ b/tests/GeminiReviewFixesTest.php @@ -0,0 +1,74 @@ +upsert(['first_name' => 'Ada', 'email' => 'a@x.com']); + + $sql = $GLOBALS['wpdb']->last_query; + + $this->assertStringContainsString('(email, first_name)', $sql, 'columns must be sorted to match the ksorted row values'); + $this->assertStringContainsString("('a@x.com', 'Ada')", $sql); + } + + /** upsert: an explicit `created_at` in the update list must be rewritten to `updated_at`. */ + public function testUpsertSwapsCreatedAtForUpdatedAtOnDuplicate(): void + { + User::query()->upsert( + ['email' => 'a@x.com', 'created_at' => '2020-01-01 00:00:00'], + ['email', 'created_at'] + ); + + $sql = $GLOBALS['wpdb']->last_query; + + $this->assertStringContainsString('updated_at = VALUES(created_at)', $sql); + $this->assertStringNotContainsString('created_at = VALUES(created_at)', $sql); + } + + /** The documented `boolean` cast must convert the value, like `bool`. */ + public function testBooleanCastConvertsValue(): void + { + $model = new CastModel(['flag' => '1']); + + $this->assertSame(true, $model->flag); + } + + /** Collection::pluck must resolve dynamic (accessor) attributes on models. */ + public function testPluckResolvesAccessorAttribute(): void + { + $collection = new Collection([new AccessorModel(['id' => 1])]); + + $this->assertSame(['L'], $collection->pluck('label')->all()); + } + + /** withCast() on the builder must stay chainable (return the QueryBuilder). */ + public function testWithCastOnBuilderIsChainable(): void + { + $this->assertInstanceOf(QueryBuilder::class, User::query()->withCast(['flag' => 'bool'])); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 6a770fc..8c1236f 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -120,3 +120,5 @@ public function get_results($query) require __DIR__ . '/Fixtures/SoftPost.php'; require __DIR__ . '/Fixtures/EventUser.php'; require __DIR__ . '/Fixtures/RetrieveUser.php'; +require __DIR__ . '/Fixtures/CastModel.php'; +require __DIR__ . '/Fixtures/AccessorModel.php'; From d35cd2a85a021820bc8b19964c134a351ce7da93 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Mon, 29 Jun 2026 19:47:15 +0600 Subject: [PATCH 049/102] docs: count() int return, boolean cast, internal namespace move MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - breaking-changes §3: count() returns int and preserves a real 0; note the Concerns/ + Query\Grammar internal relocation (public API unchanged). - §4.5: bool/boolean cast; chainable QueryBuilder::withCast(). Assisted-By: AI --- docs/breaking-changes.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/breaking-changes.md b/docs/breaking-changes.md index 6f15f4a..de7a144 100644 --- a/docs/breaking-changes.md +++ b/docs/breaking-changes.md @@ -286,7 +286,7 @@ Not signature breaks, but observable runtime differences. - **`count()` now counts the primary key** (`COUNT(pk)`) via the new `aggregate()` helper, instead of `COUNT(*)`. Rows with a NULL primary key are - no longer counted. + no longer counted. Returns `int`; a real `0` is preserved (not coerced to `null`). - **`min()` / `max()` run on a clone** of the builder, so calling them no longer mutates the current query's `select`/`selectRaw` state. - **Bulk insert id collection fixed** — inserted ids are now derived from @@ -299,6 +299,9 @@ Not signature breaks, but observable runtime differences. count no longer conflict. - **`Model` now fires lifecycle events** during read/write — see §4.3. A model with custom logic in overridden write paths may observe new event callbacks. +- **Internal layout:** the three traits moved to `BitApps\WPDatabase\Concerns` + and SELECT compilation extracted to `BitApps\WPDatabase\Query\Grammar`. Public + classes are unchanged; only code importing those internals directly is affected. --- @@ -386,10 +389,10 @@ User::query()->with('posts')->where('active', 1)->get(); ### 4.5 Other additions - **QueryBuilder:** `addSelect()`, `selectRaw()`, `orderByRaw()`, `upsert()`, - `when()`, `toSql()`, `clone()`, `aggregate()`, `prepareColumnName()`, and - `__call()` forwarding to the bound model. + `when()`, `toSql()`, `clone()`, `aggregate()`, `prepareColumnName()`, + `withCast()` (chainable), and `__call()` forwarding to the bound model. - **Model:** `query()` (canonical static builder entry), `toArray()`, - `getPrefix()`, `withCast(array $casts)`, `bool` cast. + `getPrefix()`, `withCast(array $casts)`, `bool`/`boolean` cast. - **Connection:** `startTransaction()`, `commit()`, `rollback()`. - **Blueprint:** `unique($column = null)` — optional arg (backward compatible) for composite/explicit unique indexes. From 97e5d1dd6c4cf5273654651659bc05109b84ac7a Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Mon, 29 Jun 2026 20:37:46 +0600 Subject: [PATCH 050/102] docs: rewrite README as quick-start + doc index Assisted-By: AI --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e31b7bc..c2e3f93 100644 --- a/README.md +++ b/README.md @@ -42,5 +42,6 @@ $active = Contact::where('is_active', 1) ## Documentation - **[Usage guide](docs/usage.md)** — models, query builder, relationships, - casts, events, transactions, schema builder, and more. + casts, events, transactions, and more. +- **[Schema builder](docs/schema.md)** — table creation, columns, indexes, and migrations. - **[Breaking changes](docs/breaking-changes.md)** — upgrade notes. From 393c39e1e96f318fed356a1bd9ca5ce04ab10d28 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Mon, 29 Jun 2026 20:41:41 +0600 Subject: [PATCH 051/102] docs(usage): audience note, accurate setup + model properties Assisted-By: AI --- docs/usage.md | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index e666067..ac0df58 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -4,6 +4,10 @@ A small ActiveRecord ORM + fluent query/schema builder on top of WordPress' `$wpdb`. Models map to tables, queries are built fluently, results come back as hydrated model objects inside a `Collection`. +Intended audience: WordPress plugin developers comfortable with PHP and Composer. +The API is modelled on Eloquent's; prior Eloquent experience is helpful but not +required — every method used in this guide is documented in full here. + - [Install](#install) - [Setup](#setup) - [Defining models](#defining-models) @@ -91,7 +95,7 @@ class Contact extends Model { protected $table = 'contacts'; // optional; default derived from class name protected $primaryKey = 'id'; // default 'id' - protected $prefix = ''; // per-model extra prefix (default plugin prefix) + protected $prefix = ''; // overrides the plugin prefix when non-empty public $timestamps = true; // auto-maintain created_at / updated_at @@ -104,20 +108,21 @@ class Contact extends Model 'is_active' => 'bool', ]; - // Enable soft deletes (requires a deleted_at column). + // Soft deletes: delete() sets deleted_at instead of removing the row. + // NOTE: reads are NOT filtered — soft-deleted rows appear in all() and every query. public $soft_deletes = true; } ``` | Property | Purpose | |---|---| -| `$table` | Table name (without prefix). Auto-derived if unset. | +| `$table` | Table name (without prefix). Auto-derived from the class name (pluralised, snake_cased) if unset. | | `$primaryKey` | Primary key column (default `id`). | -| `$prefix` | Extra per-model prefix. | -| `$fillable` | Mass-assignment allow-list. Unset = allow all. | -| `$casts` | Map of column → cast type. | -| `$timestamps` | Auto-set `created_at`/`updated_at` (default `true`). | -| `$soft_deletes` | `delete()` sets `deleted_at` instead of removing the row. | +| `$prefix` | When non-empty, **replaces** the plugin prefix for this model only. Table resolves to `$wpdb->prefix . $prefix . $tableName`. Leave empty (the default) to use the plugin prefix set via `Connection::setPluginPrefix()`. | +| `$fillable` | Mass-assignment allow-list. Unset = allow all non-timestamp, non-PK attributes. | +| `$casts` | Map of column → cast type. See [Attribute casting](#attribute-casting). | +| `$timestamps` | Auto-set `created_at`/`updated_at` on insert/update (declared `true` in the base `Model`; set to `false` to disable). Requires the columns to exist — see [`timestamps()` in `docs/schema.md`](schema.md#timestamps). | +| `$soft_deletes` | Must be declared `true` on the model. `delete()` then sets `deleted_at` instead of removing the row. **Reads are not filtered** — soft-deleted rows are returned by `all()` and every query. See [Limitations](#limitations--known-issues) and [`softDeletes()` in `docs/schema.md`](schema.md#softdeletes). | --- From 77fc7c78f8f874919302cee79918591e60345c1f Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Mon, 29 Jun 2026 20:46:28 +0600 Subject: [PATCH 052/102] docs(usage): correct CRUD return types, soft-delete + upsert caveats Assisted-By: AI --- docs/usage.md | 58 +++++++++++++++++++++++++++++++++------------------ 1 file changed, 38 insertions(+), 20 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index ac0df58..4e53d47 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -135,7 +135,7 @@ $contact->first_name = 'Ada'; $contact->email = 'ada@example.com'; $saved = $contact->save(); // returns the saved Model on success, false on failure -// Mass-create via insert() +// Single-row insert — returns the created Model on success, false on failure $contact = Contact::insert([ 'first_name' => 'Ada', 'email' => 'ada@example.com', @@ -156,21 +156,28 @@ it already exists. On success it returns the model; on failure, `false`. ## Reading records ```php -Contact::find(1); // by primary key → single Model (or false) -Contact::find(['email' => 'a@x.com']); // by attributes -Contact::findOne(['email' => 'a@x.com']); +// find() applies WHERE conditions and returns a Collection — not a single Model +Contact::find(1); // by PK → Collection (or [] if none) +Contact::find(['email' => 'a@x.com']); // by attributes → Collection -Contact::first(); // first row -Contact::all(); // all rows → Collection -Contact::get(); // all rows → Collection -Contact::get(['id', 'email']); // only some columns +// Use findOne() or first() when you want exactly one Model back +Contact::findOne(['email' => 'a@x.com']); // → single Model or [] +Contact::where('email', 'a@x.com')->first(); // → single Model or [] -Contact::where('is_active', 1)->get(); // filtered → Collection +Contact::first(); // first row → single Model or [] +Contact::all(); // all rows → Collection +Contact::get(); // all rows → Collection +Contact::get(['id', 'email']); // only some columns + +Contact::where('is_active', 1)->get(); // filtered → Collection ``` -- A query returning **multiple** rows yields a [`Collection`](#collections). -- A single-row read (e.g. `find` by PK, `first`) yields a `Model`. -- An empty result yields `[]`. +- `find()` sets WHERE conditions and calls `get()` — it always returns a **`Collection`** + (or `[]` when the result is empty, `false` on a database error). It does **not** return a + single Model, even when called with a single primary key. +- For a single-row read use `findOne(['col' => $val])` or chain `->first()` on a builder. + Both call `LIMIT 1` internally and return a `Model` on success or `[]` on miss. +- A query returning multiple rows returns a [`Collection`](#collections). --- @@ -311,17 +318,18 @@ $contacts->count(); ## Updating records ```php -// On an existing model — updates dirty attributes only -$contact = Contact::find(1); +// On an existing model — set attributes then save; returns Model or false +$contact = Contact::findOne(['id' => 1]); $contact->email = 'new@x.com'; -$contact->save(); +$contact->save(); // returns the Model on success, false on failure -// Conditional bulk update — executes immediately +// Conditional bulk update — executes immediately, returns int (rows affected) or false Contact::where('is_active', 0)->update(['status' => 'archived']); ``` -> `update()` executes immediately (it is not chainable). Set conditions -> **before** calling it. +> `update()` on a builder executes immediately (it is not chainable). Set conditions +> **before** calling it. When updating a model instance through `save()`, the return +> value is the `Model` on success or `false` on failure. --- @@ -330,7 +338,7 @@ Contact::where('is_active', 0)->update(['status' => 'archived']); ```php Contact::where('id', 1)->delete(); // DELETE ... WHERE id = 1 -$contact = Contact::find(1); +$contact = Contact::findOne(['id' => 1]); $contact->delete(); // deletes that row Contact::destroy([1, 2, 3]); // delete by primary keys @@ -340,7 +348,8 @@ Contact::destroy([1, 2, 3]); // delete by primary keys `RuntimeException('SQL query is empty')` — a guard against wiping the table. Use a raw `TRUNCATE` if you really mean to empty it. - With `$soft_deletes = true`, `delete()` sets `deleted_at` instead of removing - the row. + the row. **Reads are not filtered** — soft-deleted rows are returned by `all()` + and every query; there is no automatic scope. See [Limitations](#limitations--known-issues). --- @@ -362,6 +371,15 @@ Contact::query()->upsert([ ]); ``` +> **MySQL only** — `upsert()` emits `INSERT … ON DUPLICATE KEY UPDATE` and will not +> work on other databases. +> +> **`updated_at` quirk** — when `updated_at` is in the conflict-update set, the +> generated SQL maps `updated_at = VALUES(created_at)` rather than +> `VALUES(updated_at)`. This is a known implementation detail; the timestamp will +> reflect the value written to `created_at` at insert time, not a separate +> `updated_at` value. See [Limitations](#limitations--known-issues). + --- ## Attribute casting From 01e1b7b2cb40e1e1a25597710f7ba2648af8f4a0 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Mon, 29 Jun 2026 20:49:43 +0600 Subject: [PATCH 053/102] =?UTF-8?q?docs(usage):=20query=20builder=20?= =?UTF-8?q?=E2=80=94=20count=20int,=20LIKE,=20join=20caveat,=20paginate=20?= =?UTF-8?q?shape?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-By: AI --- docs/usage.md | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index 4e53d47..c9a65c5 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -224,6 +224,9 @@ Contact::whereNotNull('email')->get(); Contact::whereBetween('age', 18, 65)->get(); Contact::orWhereBetween('score', 0, 50)->get(); +// LIKE — pass the operator explicitly; the value is bound safely via %s +Contact::where('email', 'LIKE', '%@x.com')->get(); + // Raw Contact::whereRaw('YEAR(created_at) = %d', [2026])->get(); Contact::orWhereRaw('email LIKE %s', ['%@x.com'])->get(); @@ -248,6 +251,10 @@ Contact::orderByRaw('FIELD(status, %s, %s)', ['new', 'open'])->get(); Contact::groupBy('city')->having('city', '!=', '')->get(); ``` +> A bare `desc()` or `asc()` call without a prior `orderBy()` defaults the order +> column to the model's primary key — e.g. `Contact::desc()->get()` emits +> `ORDER BY id DESC`. + ### Joins ```php @@ -258,6 +265,12 @@ Contact::query() // also: rightJoin(), fullJoin(), crossJoin(), on(), orOn() ``` +> **Joins are currently unreliable.** The join implementation has known bugs: +> the joined table name is double-prefixed (`wp_wp_*`), ON-clause columns are not +> adjusted when an alias is present, and `prepareOn` reuses the mutated column +> value for the second-column lookup. Use raw SQL (`whereRaw` / `raw()`) until +> these are fixed. See [Limitations](#limitations--known-issues). + ### Limit / offset / pagination ```php @@ -278,9 +291,9 @@ $page = Contact::where('is_active', 1)->paginate($pageNo = 1, $perPage = 20); ### Aggregates ```php -Contact::where('is_active', 1)->count(); // int|null -Contact::max('score'); // mixed -Contact::min('score'); // mixed +Contact::where('is_active', 1)->count(); // int — always (returns 0, not null, on no rows) +Contact::max('score'); // mixed|null — null when the result set is empty +Contact::min('score'); // mixed|null — null when the result set is empty ``` ### Inspecting the SQL From 48de6885451ec4666114ce663e78cfb0d6cf674c Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Mon, 29 Jun 2026 20:52:38 +0600 Subject: [PATCH 054/102] docs(usage): verify collection methods + cast types, fix cast timing Assisted-By: AI --- docs/usage.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index c9a65c5..544ee9a 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -314,6 +314,9 @@ Multi-row reads return `BitApps\WPDatabase\Collection`, which implements ```php $contacts = Contact::where('is_active', 1)->get(); +// Wrap an existing array in a Collection +$collection = Collection::make($items); + $contacts->map(fn ($c) => $c->email); $contacts->filter(fn ($c) => $c->score > 50); $contacts->pluck('email'); // Collection of emails @@ -397,7 +400,7 @@ Contact::query()->upsert([ ## Attribute casting -Declare `$casts` to convert attribute values on read. Supported types: +Declare `$casts` to convert attribute values during `fill()` — both when hydrating query results and during mass-assignment. Supported types: | Cast | Result | |---|---| @@ -417,7 +420,7 @@ protected $casts = [ ``` `NULL` values are returned as `null` (not cast). Add casts at runtime with -`withCast()`: +`withCast()`, which returns the builder and is chainable: ```php Contact::query()->withCast(['is_active' => 'bool'])->get(); From dc93c3ebb961efb4b3bab73b1748537d5ed84487 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Mon, 29 Jun 2026 20:57:50 +0600 Subject: [PATCH 055/102] docs(usage): correct belongsTo/hasOne semantics + relation aggregates - Replace broken Deal::belongsTo(Contact::class,'contact_id','id') example which generated WHERE contacts.contact_id=deals.id (wrong column table). Correct call is belongsTo(Contact::class,'id','contact_id') which generates WHERE contacts.id IN (SELECT contact_id FROM deals). - Document that hasOne() is a literal alias of belongsTo() (oneToOne type, same implementation); no separate reverse-direction exists. - Clarify key convention: $foreignKey = column on related table, $localKey = column on calling table (applies to all relation methods). - Add belongsToMany() caveat: declared but has no pivot-table logic. - Show 'relation as alias' aliasing form for aggregate methods. - Note that withMin/Max/Avg/Sum require 'relation.column' form; bare relation name defaults to * which is only meaningful for count/exists. Assisted-By: AI --- docs/usage.md | 72 +++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 53 insertions(+), 19 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index 544ee9a..7f41837 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -433,47 +433,71 @@ Contact::query()->withCast(['is_active' => 'bool'])->get(); Define relationships as methods on the model. The relation method returns a query for the related model. -```php -class Deal extends Model -{ - public function contact() - { - return $this->belongsTo(Contact::class, 'contact_id', 'id'); - } -} +**Key convention:** `$foreignKey` is the column on the *related* model's table; +`$localKey` is the column on the *calling* model's table. Always supply both +explicitly when your column names differ from the ORM default +(`{callerTable}_id` / `id`). +```php class Contact extends Model { public function deals() { + // foreignKey='contact_id' lives on the deals table + // localKey='id' lives on the contacts table + // Generated predicate: WHERE deals.contact_id IN (SELECT id FROM contacts) return $this->hasMany(Deal::class, 'contact_id', 'id'); } public function profile() { + // foreignKey='contact_id' on the profiles table + // localKey='id' on the contacts table return $this->hasOne(Profile::class, 'contact_id', 'id'); } } + +class Deal extends Model +{ + public function contact() + { + // Deal.contact_id references Contact.id + // foreignKey='id' lives on the contacts (related) table + // localKey='contact_id' lives on the deals (this) table + // Generated predicate: WHERE contacts.id IN (SELECT contact_id FROM deals) + return $this->belongsTo(Contact::class, 'id', 'contact_id'); + } +} ``` +> **`hasOne` and `belongsTo` are identical.** `hasOne()` is a direct alias of +> `belongsTo()` — both set the `oneToOne` relation type and return a single +> model. There is no separate reverse-direction implementation; the direction is +> determined entirely by the keys you provide and which model calls the method. + Available: `hasOne()`, `hasMany()`, `belongsTo()`, `belongsToMany()` — each takes `($model, $foreignKey = null, $localKey = null)`. +> **`belongsToMany` is non-functional for pivot tables.** The method is +> declared but contains no pivot-table join logic. Calling it will not produce a +> many-to-many query through an intermediate table. See +> [Limitations](#limitations--known-issues). + ### Eager loading ```php Contact::with('deals')->get(); // load deals for each contact Contact::with(['deals', 'profile'])->get(); // multiple relations -// Constrain the relation +// Constrain the eager-loaded relation Contact::with('deals', function ($q) { $q->where('status', 'open'); })->get(); -// Alias the loaded relation +// Alias the loaded relation key on the result model Contact::with('deals as open_deals')->get(); -// Access after loading +// Access loaded relations foreach (Contact::with('deals')->get() as $contact) { foreach ($contact->deals as $deal) { /* ... */ } } @@ -484,12 +508,12 @@ foreach (Contact::with('deals')->get() as $contact) { ## Relation aggregates & existence ```php -Contact::withCount('deals')->get(); // adds `deals_count` -Contact::withSum('deals.amount')->get(); -Contact::withAvg('deals.amount')->get(); -Contact::withMin('deals.amount')->get(); -Contact::withMax('deals.amount')->get(); -Contact::withExists('deals')->get(); // adds bool-cast `deals_exists` +Contact::withCount('deals')->get(); // adds `deals_count` +Contact::withSum('deals.amount')->get(); // adds `deals_sum` +Contact::withAvg('deals.amount')->get(); // adds `deals_avg` +Contact::withMin('deals.amount')->get(); // adds `deals_min` +Contact::withMax('deals.amount')->get(); // adds `deals_max` +Contact::withExists('deals')->get(); // adds bool-cast `deals_exists` // Filter by relation existence Contact::whereHas('deals')->get(); @@ -499,8 +523,18 @@ Contact::whereHas('deals', fn ($q) => $q->where('status', 'open'))->get(); Contact::withWhereHas('deals', fn ($q) => $q->where('status', 'open'))->get(); ``` -Aggregate columns are aliased `_` by default (e.g. -`deals_count`); use `'deals as x'` to alias. +Aggregate columns are aliased `_` by default +(e.g. `deals_count`, `deals_sum`). Pass `'relation as alias'` to use a custom +column name: + +```php +Contact::withCount('deals as total_deals')->get(); // adds `total_deals` +Contact::withSum('deals.amount as revenue')->get(); // adds `revenue` +``` + +For `withMin`, `withMax`, `withAvg`, and `withSum` the column to aggregate must +be specified as `'relation.column'`; passing just the relation name defaults to +`*`, which is meaningful only for `withCount` and `withExists`. --- From bbfb6e0ff0a4da205716a159692dc080efdecebf Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Mon, 29 Jun 2026 21:03:42 +0600 Subject: [PATCH 056/102] docs(usage): events (drop creating/created), schema teaser, Limitations section - Model events: remove creating/created from example and events list; they fire internally but HasEvents has no registrar for them. Note boot hooks (booting/booted) are overridable methods, not Closure registrars. Add saving/saved fire on both insert and update. - Schema builder: replace full column/modifier/FK/altering content with a one-screen teaser + link to docs/schema.md (detail moves to Task 8). - Add ## Limitations & known issues section covering joins (double-prefix, ON-column, prepareOn reuse), belongsToMany pivot, belongsTo/hasOne alias + reversed key naming, soft-delete write-only, upsert MySQL-only + updated_at bug, creating/created unsubscribable, bulk insert bare-array fallback. - TOC: add Limitations & known issues entry. Assisted-By: AI --- docs/usage.md | 135 ++++++++++++++++++++++++-------------------------- 1 file changed, 65 insertions(+), 70 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index 7f41837..c18e4b2 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -27,6 +27,7 @@ required — every method used in this guide is documented in full here. - [Schema builder](#schema-builder) - [Static vs instance calls (IDE notes)](#static-vs-instance-calls-ide-notes) - [Breaking changes](#breaking-changes) +- [Limitations & known issues](#limitations--known-issues) --- @@ -542,7 +543,7 @@ be specified as `'relation.column'`; passing just the relation name defaults to Models fire lifecycle events. Register handlers in a static `boot()` using the event registrars. A pre-event (`saving`, `updating`, `deleting`) that returns -`false` aborts the operation. +`false` aborts the operation. `saving`/`saved` fire on both insert and update. ```php class Contact extends Model @@ -555,7 +556,7 @@ class Contact extends Model } }); - static::created(fn ($model) => Log::info("created {$model->id}")); + static::saved(fn ($model) => Log::info("saved {$model->id}")); // A handler can also be a class with a handle() method static::deleted(SyncDeletion::class); @@ -563,8 +564,17 @@ class Contact extends Model } ``` -Events: `booting`, `booted`, `retrieved`, `saving`, `saved`, `creating`, -`created`, `updating`, `updated`, `deleting`, `deleted`. +**Subscribable events** (registered via Closure using the methods below): +`retrieved`, `saving`, `saved`, `updating`, `updated`, `deleting`, `deleted`. + +**Boot hooks** — override these protected static methods instead of registering +a Closure: `booting()` (runs before `boot()`), `booted()` (runs after `boot()`). +The `boot()` method itself is the standard entry point for registering event +handlers. + +> `creating`/`created` events fire internally but have no public registrar — +> they cannot be subscribed via `static::creating(...)` / `static::created(...)`. +> Use `saving`/`saved` instead, which fire on both insert and update. > The `HasEvents` trait reserves the method names `boot`, `booting`, `booted`, > `fireEvent`, `fireCustomEvent`, `registerEvent` and the properties `$events`, @@ -615,77 +625,17 @@ table prefix is applied automatically. use BitApps\WPDatabase\Schema; Schema::create('contacts', function ($table) { - $table->id(); // BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY - $table->string('email'); // VARCHAR(255) + $table->id(); + $table->string('email'); $table->string('first_name')->nullable(); - $table->int('age')->unsigned()->defaultValue(0); - $table->text('bio')->nullable(); $table->bool('is_active')->defaultValue(1); - $table->json('meta')->nullable(); - - $table->unique('email'); // unique index - $table->unique(['first_name', 'last_name']); // composite unique - $table->index(); // index last column - - $table->timestamps(); // created_at + updated_at - $table->softDeletes(); // deleted_at + $table->timestamps(); }); ``` -### Column types - -`char`, `varchar`, `json`, `binary`, `varbinary`, `tinyblob`, `tinytext`, -`text`, `blob`, `mediumtext`, `mediumblob`, `longtext`, `longblob`, `enum`, -`set`, `bit`, `tinyint`, `bool`/`boolean`, `smallint`, `mediumint`, -`int`/`integer`, `bigint`, `float`, `double`, `double_precision`, `decimal`/ -`dec`, `date`, `datetime`, `timestamp`, `time`, `year`. Helpers: `id()`, -`increments()`, `string()`, `timestamps()`, `softDeletes()`. - -### Column modifiers - -```php -$table->int('views')->unsigned()->zeroFill(); -$table->string('slug')->nullable()->defaultValue(''); -$table->bigint('user_id')->primary(); -$table->decimal('price')->length([10, 2]); -``` - -`nullable()`, `defaultValue($v)`, `unsigned()`, `zeroFill()`, `primary()`, -`unique($column = null)`, `index($type = null)`, `length($n)`. - -### Foreign keys - -```php -Schema::create('deals', function ($table) { - $table->id(); - $table->bigint('contact_id')->unsigned(); - $table->foreign('contacts', 'id')->onDelete()->cascade(); - // FK on the previously-defined column (contact_id) → contacts(id) -}); -``` - -`foreign($referencedTable, $referencedColumn)` applies to the **last defined -column**, then one of `cascade()`, `restrict()`, `setNull()`, optionally scoped -by `onDelete()` / `onUpdate()`. - -### Altering & dropping - -```php -Schema::drop('contacts'); -Schema::rename('contacts', 'people'); - -Schema::edit('contacts', function ($table) { - $table->string('phone')->nullable(); // ADD COLUMN - $table->dropColumn('bio'); - $table->dropTimestamps(); - $table->dropIndex(['email_UNIQUE']); - $table->dropForeign(['fk_name']); - $table->dropPrimary(); -}); - -// Scope to a custom prefix -Schema::withPrefix('wp_other_')->create('logs', function ($table) { /* ... */ }); -``` +For the full reference — column types, modifiers, foreign keys, `Schema::edit`, +`Schema::drop`, `Schema::rename`, and prefix scoping — see +[Schema builder reference](schema.md). --- @@ -714,3 +664,48 @@ If you are upgrading, read [breaking-changes.md](breaking-changes.md) — it covers the `Collection` return type, `update()`/`save()` behavior, `select()` quoting, the `delete()`-without-WHERE guard, null casting, and the relation method relocation. + +--- + +## Limitations & known issues + +- **Joins are broadly unreliable.** The `prepareOn()` helper double-prefixes + table names (`wp_wp_*`) when a plugin prefix is set, and ON-clause columns + are not prefixed at all. The mutated column name is also reused on subsequent + calls. Workaround: write raw JOIN clauses via `raw()` and apply your own + prefix via `Connection::getPrefix()`. + +- **`belongsToMany` is declared but non-functional.** The method exists but + contains no pivot-table join logic. Calling it does not produce a + many-to-many query through an intermediate table. Workaround: model the pivot + as an explicit intermediate model with `hasMany` on each side. + +- **`belongsTo` and `hasOne` are the same alias; key naming is reversed from + Laravel.** Both set the same `oneToOne` relation. The `$foreignKey` argument + is the column on the **related** table and `$localKey` is the column on the + **calling** model's table — the opposite of Laravel's convention. Ensure you + supply both arguments explicitly to avoid confusion. + +- **Soft delete is write-only.** `softDeletes()` adds a `deleted_at` column and + `delete()` sets it, but there is no global scope to filter soft-deleted rows + from queries. Every `get()` / `find()` returns soft-deleted rows alongside + live ones. Workaround: add `->whereNull('deleted_at')` to every read query. + +- **`upsert` is MySQL-only.** It generates `INSERT … ON DUPLICATE KEY UPDATE`, + which is not portable to other databases. Additionally, the generated SQL sets + `updated_at = VALUES(created_at)` instead of `VALUES(updated_at)`, so the + timestamp may be wrong on update. Workaround: use separate `insert` + `update` + calls where portability or correct timestamps are required. + +- **`creating`/`created` events cannot be subscribed.** These event names fire + internally during `insert()` but `HasEvents` provides no registrar methods for + them. Calling `static::creating(fn ...)` or `static::created(fn ...)` in + `boot()` will throw a fatal error. Use `saving`/`saved` instead — they fire + on both insert and update. + +- **Bulk `insert()` may return a bare array, not a `Collection`.** When the + post-insert re-query that hydrates the inserted rows fails, the fallback path + returns a plain PHP array of IDs rather than a `Collection`. Code that calls + Collection methods on the return value of `insert()` will break in that case. + Workaround: check `is_array()` on the result or use `Collection::make()` to + wrap it defensively. From 5788723248a7c5d17ab0ec368c3dfc1020837572 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Mon, 29 Jun 2026 21:08:37 +0600 Subject: [PATCH 057/102] docs: add schema builder reference (docs/schema.md) Assisted-By: AI --- docs/schema.md | 319 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 319 insertions(+) create mode 100644 docs/schema.md diff --git a/docs/schema.md b/docs/schema.md new file mode 100644 index 0000000..d69c824 --- /dev/null +++ b/docs/schema.md @@ -0,0 +1,319 @@ +# Schema Builder Reference + +Use `Schema` (a thin facade over `Blueprint`) to create, alter and drop MySQL tables from +PHP. The table prefix (WordPress prefix + plugin prefix) is applied automatically — pass +bare table names everywhere. + +See [Defining models](usage.md#defining-models) for the model-side `$timestamps` and +`$soft_deletes` properties that rely on the columns described here. + +--- + +- [Creating tables](#creating-tables) +- [Column types](#column-types) + - [String / binary](#string--binary) + - [Text / blob](#text--blob) + - [Numeric](#numeric) + - [Enumerated](#enumerated) + - [Date / time](#date--time) +- [Column modifiers](#column-modifiers) +- [Column helpers](#column-helpers) + - [id()](#id) + - [increments()](#increments) + - [string()](#string) + - [timestamps()](#timestamps) + - [softDeletes()](#softdeletes) +- [Foreign keys](#foreign-keys) +- [Altering & dropping](#altering--dropping) +- [Table operations](#table-operations) + +--- + +## Creating tables + +```php +use BitApps\WPDatabase\Schema; + +Schema::create('orders', function ($table) { + $table->id(); + $table->bigint('user_id')->unsigned(); + $table->varchar('status', 32)->defaultValue('pending'); + $table->decimal('total')->nullable(); + $table->timestamps(); + + $table->bigint('user_id')->unsigned() + ->foreign('users', 'id') + ->onDelete()->cascade(); +}); +``` + +The callback receives a `Blueprint` instance. Column-definition methods return the same +`Blueprint`, so modifiers chain directly: + +```php +$table->varchar('slug', 100)->nullable()->unique(); +``` + +--- + +## Column types + +All types listed below are registered in `Blueprint::isValidType()` and exposed as magic +methods via `Blueprint::__call()`. The first argument is always the column name; the +optional second argument sets the length (or, for `ENUM`/`SET`, the list of allowed values +as an array). + +### String / binary + +| Method | SQL type | Notes | +|---|---|---| +| `char($name, $length = null)` | `CHAR` | | +| `varchar($name, $length = null)` | `VARCHAR` | | +| `binary($name, $length = null)` | `BINARY` | Column **type** — see note | +| `varbinary($name, $length = null)` | `VARBINARY` | Column **type** — see note | +| `json($name, $length = null)` | `JSON` | | + +> **`binary` / `varbinary` are column types, not modifiers.** These define columns +> with the `BINARY` / `VARBINARY` SQL type. A chained `binary()` *modifier* (which +> would have flagged an existing column as binary) was removed in an earlier refactor; +> only the type declarations remain. Use `$table->binary('data', 16)` to define a +> `BINARY(16)` column. + +### Text / blob + +| Method | SQL type | +|---|---| +| `tinytext($name)` | `TINYTEXT` | +| `text($name)` | `TEXT` | +| `mediumtext($name)` | `MEDIUMTEXT` | +| `longtext($name)` | `LONGTEXT` | +| `tinyblob($name)` | `TINYBLOB` | +| `blob($name)` | `BLOB` | +| `mediumblob($name)` | `MEDIUMBLOB` | +| `longblob($name)` | `LONGBLOB` | + +### Numeric + +| Method | SQL type | Notes | +|---|---|---| +| `bit($name, $length = null)` | `BIT` | | +| `tinyint($name, $length = null)` | `TINYINT` | | +| `bool($name)` | `BOOL` | | +| `boolean($name)` | `BOOLEAN` | Alias of `bool` | +| `smallint($name, $length = null)` | `SMALLINT` | | +| `mediumint($name, $length = null)` | `MEDIUMINT` | | +| `int($name, $length = null)` | `INT` | | +| `integer($name, $length = null)` | `INTEGER` | Alias of `int` | +| `bigint($name, $length = null)` | `BIGINT` | | +| `float($name, $length = null)` | `FLOAT` | | +| `double($name, $length = null)` | `DOUBLE` | | +| `double_precision($name, $length = null)` | `DOUBLE PRECISION` | Underscore maps to space | +| `decimal($name, $length = null)` | `DECIMAL` | | +| `dec($name, $length = null)` | `DEC` | Alias of `decimal` | + +### Enumerated + +| Method | SQL type | Notes | +|---|---|---| +| `enum($name, array $enum)` | `ENUM` | Second arg is an array of allowed string values | +| `set($name, array $set)` | `SET` | Second arg is an array of allowed string values | + +```php +$table->enum('status', ['draft', 'published', 'archived']); +$table->set('permissions', ['read', 'write', 'delete']); +``` + +### Date / time + +| Method | SQL type | +|---|---| +| `date($name)` | `DATE` | +| `datetime($name)` | `DATETIME` | +| `timestamp($name)` | `TIMESTAMP` | +| `time($name)` | `TIME` | +| `year($name)` | `YEAR` | + +--- + +## Column modifiers + +Chain any of these immediately after a type call. Each returns the `Blueprint` so multiple +modifiers can be stacked. + +| Modifier | Effect | +|---|---| +| `nullable()` | Emits `NULL` instead of `NOT NULL`. | +| `defaultValue($value)` | Adds `DEFAULT $value`. Integers are emitted unquoted; `CURRENT_TIMESTAMP` and `NULL` are also unquoted; all other strings are single-quoted automatically. | +| `unsigned()` | Adds `UNSIGNED` (meaningful on numeric columns). | +| `zeroFill()` | Adds `ZEROFILL`. | +| `primary()` | Registers the column in the `PRIMARY KEY` clause and forces `NOT NULL`. | +| `unique($column = null)` | Registers a `UNIQUE INDEX`. Without an argument it indexes the current column. Pass an explicit column name or an array of column names to create a named or composite unique index. | +| `index($type = null)` | Registers an `INDEX` on the current column. Pass an index-type prefix string (e.g. `'FULLTEXT '`) to customise the index clause. | +| `length($length)` | Sets the column length. Accepts an array for `ENUM`/`SET` allowed values — each element is single-quoted and the list is comma-joined automatically. | + +--- + +## Column helpers + +Higher-level helpers that expand to one or more fully configured column definitions. + +### id() + +```php +$table->id(); +``` + +Defines `id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT` and registers it as the `PRIMARY KEY`. +Shorthand for: + +```php +$table->bigint('id')->unsigned()->increments()->primary(); +``` + +### increments() + +```php +$table->bigint('sort_order')->increments(); +// or pass a name to create the column and apply AUTO_INCREMENT in one call: +$table->increments('sort_order'); // creates BIGINT sort_order NOT NULL AUTO_INCREMENT +``` + +Adds `AUTO_INCREMENT` to the most recently defined column. When called with a column name +argument it first creates a `BIGINT` column with that name, then applies `AUTO_INCREMENT`. + +### string() + +```php +$table->string('email'); +$table->string('slug')->unique(); +``` + +Defines a `VARCHAR(255)` column. Equivalent to `$table->varchar($name)->length(255)`. + +### timestamps() + +```php +$table->timestamps(); +``` + +Adds two nullable `TIMESTAMP` columns — `created_at` and `updated_at` — both defaulting +to `NULL`. + +The model-side `$timestamps = true` property (see +[Defining models](usage.md#defining-models)) instructs the ORM to auto-populate these +columns on every insert and update. The table must contain these two columns for +`$timestamps` to function. + +### softDeletes() + +```php +$table->softDeletes(); +``` + +Adds a single nullable `TIMESTAMP` column named `deleted_at`, defaulting to `NULL`. + +The model-side `$soft_deletes = true` property (see +[Defining models](usage.md#defining-models)) instructs `delete()` to set `deleted_at` +rather than removing the row. **Reads are not filtered** — soft-deleted rows are returned +by `all()` and every query; append `->whereNull('deleted_at')` manually to exclude them. + +--- + +## Foreign keys + +Foreign key constraints are declared by chaining on the column that holds the FK value: + +```php +$table->bigint('user_id')->unsigned() + ->foreign('users', 'id') // references users(id) + ->onDelete()->cascade() // ON DELETE CASCADE + ->onUpdate()->restrict(); // ON UPDATE RESTRICT +``` + +| Method | Description | +|---|---| +| `foreign($ref, $refCol)` | Declares a `FOREIGN KEY` from the current column to column `$refCol` on table `$ref`. The prefix is applied to `$ref` automatically. | +| `onDelete()` | Selects the `ON DELETE` slot for the next action modifier. | +| `onUpdate()` | Selects the `ON UPDATE` slot for the next action modifier. | +| `cascade()` | Applies `CASCADE`. If `onDelete()` or `onUpdate()` was called first, only that slot is set; otherwise both `ON DELETE` and `ON UPDATE` are set to `CASCADE`. | +| `restrict()` | Applies `RESTRICT`. Same scoping rules as `cascade()`. | +| `setNull()` | Applies `SET NULL`. Same scoping rules as `cascade()`. | + +--- + +## Altering & dropping + +Use `Schema::edit` to alter an existing table. Inside the callback, call the same column +definitions as in `Schema::create` to add columns, and use the drop/rename helpers to +modify or remove existing ones. + +```php +Schema::edit('orders', function ($table) { + $table->varchar('reference', 64)->nullable(); // ADD COLUMN + $table->tinyint('priority')->defaultValue(0); // ADD COLUMN + $table->dropColumn('legacy_notes'); // DROP COLUMN + $table->dropTimestamps(); // DROP created_at + updated_at +}); +``` + +### Modifying an existing column + +Chain `change()` on a column definition inside `Schema::edit` to emit `CHANGE COLUMN` +instead of `ADD COLUMN`: + +```php +Schema::edit('orders', function ($table) { + $table->varchar('reference', 128)->change(); // CHANGE COLUMN — widen the length +}); +``` + +### Drop helpers + +The following can be called inside a `Schema::edit` callback or as a direct static call +(e.g. `Schema::dropColumn('orders', 'legacy_notes')`). + +| Method | Emitted SQL | +|---|---| +| `dropColumn($column)` | `DROP $column` | +| `dropTimestamps()` | `DROP COLUMN created_at, DROP COLUMN updated_at` | +| `dropIndex($indexes)` | `DROP INDEX \`name\`` — accepts a single name string or an array of names | +| `dropUnique($indexes)` | Alias of `dropIndex` — unique indexes are dropped the same way as regular indexes, by name | +| `dropForeign($keys)` | `DROP FOREIGN KEY \`name\`` — accepts a single key name string or an array | +| `dropPrimary()` | `DROP PRIMARY KEY` | + +### renameColumn() + +```php +// Direct call — renames a single column on the table +Schema::renameColumn('orders', 'fname', 'first_name'); +// Emits: ALTER TABLE wp_orders CHANGE fname first_name +``` + +Renames a column via MySQL's `CHANGE` clause. Note that this implementation emits +`CHANGE old_name new_name` without repeating the column definition, which is accepted +by MySQL 8.0+ (which supports bare `RENAME COLUMN`) but may require the full type +specification on older MySQL / MariaDB versions. + +--- + +## Table operations + +All methods are available as static calls (`Schema::create(...)`) and as instance calls +on `(new Schema())->create(...)` — both are equivalent thanks to `__callStatic`. + +| Method | Description | +|---|---| +| `Schema::create($table, Closure $callback)` | `CREATE TABLE IF NOT EXISTS`. The callback receives the `Blueprint`. | +| `Schema::edit($table, Closure $callback)` | `ALTER TABLE`. Use to add columns, drop columns, add/drop indexes and foreign keys. | +| `Schema::drop($table)` | `DROP TABLE IF EXISTS`. | +| `Schema::rename($table, $newName)` | `ALTER TABLE … RENAME TO`. The prefix is applied to both names. | +| `Schema::withPrefix($prefix)` | Overrides the automatic prefix for this call. Returns a `Schema` instance; chain `.create()`, `.edit()`, etc. | + +```php +// Override prefix — table resolves as "custom_orders", not "wp_plugin_orders" +Schema::withPrefix('custom_')->create('orders', function ($table) { + $table->id(); + $table->string('reference'); + $table->timestamps(); +}); +``` From 5c0b27fa0699967aca2fb108d033531390ac8172 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Mon, 29 Jun 2026 21:13:02 +0600 Subject: [PATCH 058/102] docs(breaking-changes): fix find() return + event list accuracy Assisted-By: AI --- docs/breaking-changes.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/breaking-changes.md b/docs/breaking-changes.md index de7a144..2391de2 100644 --- a/docs/breaking-changes.md +++ b/docs/breaking-changes.md @@ -37,7 +37,8 @@ API and runtime behavior change, it is a **major** version bump. ### 2.1 `Model::get()` / query results return a `Collection`, not an `array` Multi-row reads now return `BitApps\WPDatabase\Collection` instead of a plain -PHP array. Single-row reads still return the model; empty results return `[]`. +PHP array. `find()` always returns a `Collection` (even for a single PK); use +`findOne()` or `->first()` for a single model. Empty results return `[]`. ```php // Before @@ -333,9 +334,10 @@ Relation names support an `as` alias: `withCount('posts as total_posts')`. ### 4.3 Model events (`HasEvents` trait) -Lifecycle hooks: `booting`, `booted`, `retrieved`, `saving`, `saved`, -`updating`, `updated`, `deleting`, `deleted`. Register in a model's `boot()` -via the protected static registrars, e.g.: +**Subscribable events** — register in a model's `boot()` via the named static +registrars: `retrieved`, `saving`, `saved`, `updating`, `updated`, `deleting`, +`deleted`. **Boot hooks** — override the protected static methods `booting()` +(runs before `boot()`) and `booted()` (runs after) instead of using a registrar. ```php protected static function boot() @@ -347,6 +349,9 @@ protected static function boot() A pre-event (`saving`/`updating`/`deleting`) returning `false` aborts the query. +> `creating`/`created` events fire internally but have no public registrar — +> use `saving`/`saved` instead, which fire on both insert and update. + > ⚠️ The trait **reserves** the method names `boot`, `booting`, `booted`, > `fireEvent`, `fireCustomEvent`, `registerEvent` and the properties `$events`, > `$registeredEvents`, `$booted` on subclasses. A child model already defining From 1d04deaff5fce6f77945e41351edfdbb93f11336 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Mon, 29 Jun 2026 21:34:24 +0600 Subject: [PATCH 059/102] docs: correct schema prefix/change()/direct-drop claims to match source Assisted-By: AI --- docs/schema.md | 50 +++++++++++++++++++++++++++++++++++++++----------- docs/usage.md | 24 ++++++++++++++++++------ 2 files changed, 57 insertions(+), 17 deletions(-) diff --git a/docs/schema.md b/docs/schema.md index d69c824..a3c67c4 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -1,8 +1,8 @@ # Schema Builder Reference Use `Schema` (a thin facade over `Blueprint`) to create, alter and drop MySQL tables from -PHP. The table prefix (WordPress prefix + plugin prefix) is applied automatically — pass -bare table names everywhere. +PHP. By default the table name is used as-is (no prefix is applied); call +`Schema::withPrefix($prefix)` to apply one. See [Defining models](usage.md#defining-models) for the model-side `$timestamps` and `$soft_deletes` properties that rely on the columns described here. @@ -36,14 +36,12 @@ use BitApps\WPDatabase\Schema; Schema::create('orders', function ($table) { $table->id(); - $table->bigint('user_id')->unsigned(); - $table->varchar('status', 32)->defaultValue('pending'); - $table->decimal('total')->nullable(); - $table->timestamps(); - $table->bigint('user_id')->unsigned() ->foreign('users', 'id') ->onDelete()->cascade(); + $table->varchar('status', 32)->defaultValue('pending'); + $table->decimal('total')->nullable(); + $table->timestamps(); }); ``` @@ -267,10 +265,18 @@ Schema::edit('orders', function ($table) { }); ``` +> ⚠️ **Known bug:** `change()` currently emits malformed SQL (`ADD COLUMN CHANGE COLUMN …`) +> because `addColumnQuery()` unconditionally prepends `ADD COLUMN` in edit mode and then +> also prepends `CHANGE COLUMN` when the `change` flag is set. Do not rely on `change()` +> in production until this is fixed. + ### Drop helpers -The following can be called inside a `Schema::edit` callback or as a direct static call -(e.g. `Schema::dropColumn('orders', 'legacy_notes')`). +Only `dropColumn` and `renameColumn` produce complete SQL when called as a direct static +call (e.g. `Schema::dropColumn('orders', 'legacy_notes')`). The remaining helpers — +`dropTimestamps`, `dropIndex`, `dropUnique`, `dropForeign`, and `dropPrimary` — must be +used inside a `Schema::edit()` callback; a direct static call only emits the +`ALTER TABLE` header with no `DROP` clause. | Method | Emitted SQL | |---|---| @@ -286,7 +292,8 @@ The following can be called inside a `Schema::edit` callback or as a direct stat ```php // Direct call — renames a single column on the table Schema::renameColumn('orders', 'fname', 'first_name'); -// Emits: ALTER TABLE wp_orders CHANGE fname first_name +// Emits: ALTER TABLE `orders` CHANGE fname first_name +// (wp_ prefix appears only if withPrefix was used, e.g. Schema::withPrefix('wp_')->renameColumn(...)) ``` Renames a column via MySQL's `CHANGE` clause. Note that this implementation emits @@ -310,10 +317,31 @@ on `(new Schema())->create(...)` — both are equivalent thanks to `__callStatic | `Schema::withPrefix($prefix)` | Overrides the automatic prefix for this call. Returns a `Schema` instance; chain `.create()`, `.edit()`, etc. | ```php -// Override prefix — table resolves as "custom_orders", not "wp_plugin_orders" +// Override prefix — table resolves as "custom_orders" +// (without withPrefix, the bare name "orders" is used — no prefix is applied automatically) Schema::withPrefix('custom_')->create('orders', function ($table) { $table->id(); $table->string('reference'); $table->timestamps(); }); ``` + +--- + +## Limitations & known issues + +- **Schema builder does not auto-apply the table prefix.** `Schema::$prefix` defaults to + `null`, not `''`; no prefix is prepended unless you call `Schema::withPrefix()` first. + To use the WordPress prefix, pass it explicitly: + `Schema::withPrefix($wpdb->prefix)->create(...)`. + +- **`change()` is broken — emits malformed SQL.** `addColumnQuery()` prepends + `ADD COLUMN` for every column in edit mode, then also prepends `CHANGE COLUMN` when the + `change` flag is set, producing `ADD COLUMN CHANGE COLUMN …`. Avoid `change()` in + production until this is fixed. + +- **`dropTimestamps()`, `dropIndex()`, `dropUnique()`, `dropForeign()`, `dropPrimary()` + must be used inside a `Schema::edit()` callback.** A direct static call (e.g. + `Schema::dropTimestamps('orders')`) only sets the `ALTER TABLE` header and emits no + `DROP` clause, producing a syntax error. Only `dropColumn()` and `renameColumn()` + produce complete SQL when called directly. diff --git a/docs/usage.md b/docs/usage.md index c18e4b2..8a8b408 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -618,8 +618,9 @@ Placeholders use `$wpdb` conventions (`%d`, `%s`, `%f`) with the bindings array. ## Schema builder -Use `Schema` (a facade over `Blueprint`) to create, alter and drop tables. The -table prefix is applied automatically. +Use `Schema` (a facade over `Blueprint`) to create, alter and drop tables. By +default the table name is used as-is — call `Schema::withPrefix($prefix)` to +apply a prefix. ```php use BitApps\WPDatabase\Schema; @@ -669,10 +670,12 @@ method relocation. ## Limitations & known issues -- **Joins are broadly unreliable.** The `prepareOn()` helper double-prefixes - table names (`wp_wp_*`) when a plugin prefix is set, and ON-clause columns - are not prefixed at all. The mutated column name is also reused on subsequent - calls. Workaround: write raw JOIN clauses via `raw()` and apply your own +- **Joins are broadly unreliable.** `join()` in `QueryBuilder` always prepends + `Connection::wpPrefix()` (plus the model prefix) onto the supplied table name, + causing a double prefix (`wp_wp_*`) unconditionally — not only when a plugin + prefix is set. Separately, `prepareOn()` reuses the mutated column value for the + second-column lookup, and ON-clause columns are not adjusted when an alias is + present. Workaround: write raw JOIN clauses via `raw()` and apply your own prefix via `Connection::getPrefix()`. - **`belongsToMany` is declared but non-functional.** The method exists but @@ -709,3 +712,12 @@ method relocation. Collection methods on the return value of `insert()` will break in that case. Workaround: check `is_array()` on the result or use `Collection::make()` to wrap it defensively. + +- **Schema builder does not auto-apply the table prefix.** `Schema::$prefix` + defaults to `null`, not `''`; table names are used as-is unless you call + `Schema::withPrefix()` explicitly. See [Schema builder reference](schema.md). + +- **`change()` column modifier is broken: emits malformed `ADD COLUMN CHANGE COLUMN …` + SQL.** The `addColumnQuery()` method prepends `ADD COLUMN` in edit mode and also + prepends `CHANGE COLUMN` when the `change` flag is set, producing invalid SQL. + Avoid `change()` in production until this is fixed. From cac1b1d2f4d6557373c678e5ae6a8122622fedd7 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Mon, 29 Jun 2026 21:36:35 +0600 Subject: [PATCH 060/102] docs(schema): remove residual auto-prefix claims (FK ref, rename, withPrefix) Assisted-By: AI --- docs/schema.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/schema.md b/docs/schema.md index a3c67c4..3708a4b 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -230,7 +230,7 @@ $table->bigint('user_id')->unsigned() | Method | Description | |---|---| -| `foreign($ref, $refCol)` | Declares a `FOREIGN KEY` from the current column to column `$refCol` on table `$ref`. The prefix is applied to `$ref` automatically. | +| `foreign($ref, $refCol)` | Declares a `FOREIGN KEY` from the current column to column `$refCol` on table `$ref`. `$ref` is used as-is — pass the full (already-prefixed) table name; no prefix is applied. | | `onDelete()` | Selects the `ON DELETE` slot for the next action modifier. | | `onUpdate()` | Selects the `ON UPDATE` slot for the next action modifier. | | `cascade()` | Applies `CASCADE`. If `onDelete()` or `onUpdate()` was called first, only that slot is set; otherwise both `ON DELETE` and `ON UPDATE` are set to `CASCADE`. | @@ -313,8 +313,8 @@ on `(new Schema())->create(...)` — both are equivalent thanks to `__callStatic | `Schema::create($table, Closure $callback)` | `CREATE TABLE IF NOT EXISTS`. The callback receives the `Blueprint`. | | `Schema::edit($table, Closure $callback)` | `ALTER TABLE`. Use to add columns, drop columns, add/drop indexes and foreign keys. | | `Schema::drop($table)` | `DROP TABLE IF EXISTS`. | -| `Schema::rename($table, $newName)` | `ALTER TABLE … RENAME TO`. The prefix is applied to both names. | -| `Schema::withPrefix($prefix)` | Overrides the automatic prefix for this call. Returns a `Schema` instance; chain `.create()`, `.edit()`, etc. | +| `Schema::rename($table, $newName)` | `ALTER TABLE … RENAME TO`. Both names are used as-is unless `Schema::withPrefix()` is used (no prefix by default). | +| `Schema::withPrefix($prefix)` | Sets the prefix applied for this call (there is no prefix by default). Returns a `Schema` instance; chain `.create()`, `.edit()`, etc. | ```php // Override prefix — table resolves as "custom_orders" From ed3c82d18f633627f22baeac07400fa945424e69 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Mon, 29 Jun 2026 21:39:36 +0600 Subject: [PATCH 061/102] chore: gitignore .superpowers/ scratch dir Assisted-By: AI --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 72e389b..59bab05 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ tests.config.php .phpunit.cache/ phpunit.phar composer.lock +.superpowers/ From 3041ef16a199673db24812bd582ac575d7c3902a Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Tue, 30 Jun 2026 10:32:07 +0600 Subject: [PATCH 062/102] fix: change() emits MODIFY COLUMN, not ADD COLUMN CHANGE COLUMN Blueprint::addColumnQuery() unconditionally prepended ADD COLUMN in edit mode and then also prepended CHANGE COLUMN when the change flag was set, producing invalid SQL. Make the two prefixes mutually exclusive: a changed column now emits MODIFY COLUMN; non-changed columns in edit mode still emit ADD COLUMN. Add SchemaTest with a failing-first test for both paths. Add has_cap() stub to FakeWpdb so Schema tests can boot. Remove the change() Known bug note and Limitations bullets from docs/schema.md and docs/usage.md. Assisted-By: AI --- docs/schema.md | 14 ++----------- docs/usage.md | 4 ---- src/Blueprint.php | 10 ++++----- src/Schema.php | 2 +- tests/SchemaTest.php | 49 ++++++++++++++++++++++++++++++++++++++++++++ tests/bootstrap.php | 30 +++++++++++++++++---------- 6 files changed, 75 insertions(+), 34 deletions(-) create mode 100644 tests/SchemaTest.php diff --git a/docs/schema.md b/docs/schema.md index 3708a4b..ff2581a 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -256,20 +256,15 @@ Schema::edit('orders', function ($table) { ### Modifying an existing column -Chain `change()` on a column definition inside `Schema::edit` to emit `CHANGE COLUMN` +Chain `change()` on a column definition inside `Schema::edit` to emit `MODIFY COLUMN` instead of `ADD COLUMN`: ```php Schema::edit('orders', function ($table) { - $table->varchar('reference', 128)->change(); // CHANGE COLUMN — widen the length + $table->varchar('reference', 128)->change(); // MODIFY COLUMN — widen the length }); ``` -> ⚠️ **Known bug:** `change()` currently emits malformed SQL (`ADD COLUMN CHANGE COLUMN …`) -> because `addColumnQuery()` unconditionally prepends `ADD COLUMN` in edit mode and then -> also prepends `CHANGE COLUMN` when the `change` flag is set. Do not rely on `change()` -> in production until this is fixed. - ### Drop helpers Only `dropColumn` and `renameColumn` produce complete SQL when called as a direct static @@ -335,11 +330,6 @@ Schema::withPrefix('custom_')->create('orders', function ($table) { To use the WordPress prefix, pass it explicitly: `Schema::withPrefix($wpdb->prefix)->create(...)`. -- **`change()` is broken — emits malformed SQL.** `addColumnQuery()` prepends - `ADD COLUMN` for every column in edit mode, then also prepends `CHANGE COLUMN` when the - `change` flag is set, producing `ADD COLUMN CHANGE COLUMN …`. Avoid `change()` in - production until this is fixed. - - **`dropTimestamps()`, `dropIndex()`, `dropUnique()`, `dropForeign()`, `dropPrimary()` must be used inside a `Schema::edit()` callback.** A direct static call (e.g. `Schema::dropTimestamps('orders')`) only sets the `ALTER TABLE` header and emits no diff --git a/docs/usage.md b/docs/usage.md index 8a8b408..cf46489 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -717,7 +717,3 @@ method relocation. defaults to `null`, not `''`; table names are used as-is unless you call `Schema::withPrefix()` explicitly. See [Schema builder reference](schema.md). -- **`change()` column modifier is broken: emits malformed `ADD COLUMN CHANGE COLUMN …` - SQL.** The `addColumnQuery()` method prepends `ADD COLUMN` in edit mode and also - prepends `CHANGE COLUMN` when the `change` flag is set, producing invalid SQL. - Avoid `change()` in production until this is fixed. diff --git a/src/Blueprint.php b/src/Blueprint.php index 1be96bf..4e0ea45 100644 --- a/src/Blueprint.php +++ b/src/Blueprint.php @@ -99,7 +99,7 @@ class Blueprint * @param string $prefix Table prefix * @param null|Closure $callback Closure to build the blueprint */ - public function __construct($table, $method, $prefix = '', Closure $callback = null) + public function __construct($table, $method, $prefix = '', ?Closure $callback = null) { $this->_prefix = $prefix; $this->table = "{$prefix}{$table}"; @@ -639,12 +639,10 @@ private function addColumnQuery() $query .= "\n, "; } - if ($this->method === 'edit') { - $query .= 'ADD COLUMN '; - } - if (isset($column['change'])) { - $query .= 'CHANGE COLUMN '; + $query .= 'MODIFY COLUMN '; + } elseif ($this->method === 'edit') { + $query .= 'ADD COLUMN '; } $query .= $column['name'] . ' ' . $column['type']; diff --git a/src/Schema.php b/src/Schema.php index 5e5aff0..ecc9409 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -46,7 +46,7 @@ public function __call($method, $parameters) return $this->build($blueprint); } - public function createBlueprint($schema, $method, Closure $callback = null) + public function createBlueprint($schema, $method, ?Closure $callback = null) { return new Blueprint( $schema, diff --git a/tests/SchemaTest.php b/tests/SchemaTest.php new file mode 100644 index 0000000..3437c1d --- /dev/null +++ b/tests/SchemaTest.php @@ -0,0 +1,49 @@ +varchar('reference', 128)->change(); + }); + + $sql = $GLOBALS['wpdb']->last_query; + + $this->assertStringContainsString('MODIFY COLUMN reference VARCHAR(128)', $sql); + $this->assertStringNotContainsString('ADD COLUMN', $sql); + } + + public function testNonChangedEditEmitsAddColumn(): void + { + Schema::edit('orders', function ($table) { + $table->varchar('note', 64); + }); + + $sql = $GLOBALS['wpdb']->last_query; + + $this->assertStringContainsString('ADD COLUMN note VARCHAR(64)', $sql); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 8c1236f..f7e202c 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -8,35 +8,34 @@ * so `Connection` can resolve a prefix and capture executed SQL without a real * database. */ - error_reporting(E_ALL & ~E_DEPRECATED); -if (!\defined('ABSPATH')) { - \define('ABSPATH', __DIR__ . '/'); +if (!defined('ABSPATH')) { + define('ABSPATH', __DIR__ . '/'); } -if (!\function_exists('esc_html')) { +if (!function_exists('esc_html')) { function esc_html($text) { return $text; } } -if (!\function_exists('wp_json_encode')) { +if (!function_exists('wp_json_encode')) { function wp_json_encode($data, $options = 0, $depth = 512) { return json_encode($data, $options, $depth); } } -if (!\function_exists('get_option')) { +if (!function_exists('get_option')) { function get_option($name, $default = false) { return $default; } } -if (!\function_exists('wp_timezone_string')) { +if (!function_exists('wp_timezone_string')) { function wp_timezone_string() { return 'UTC'; @@ -63,10 +62,14 @@ class FakeWpdb public $suppress_errors = false; - /** @var string[] */ + /** + * @var string[] + */ public $queries = []; - /** @var null|callable resolves a result set from the SQL string */ + /** + * @var null|callable resolves a result set from the SQL string + */ public $resolver; public function queueResult(array $rows) @@ -79,7 +82,7 @@ public function query($sql) $this->last_query = $sql; $this->queries[] = $sql; - if (\is_callable($this->resolver)) { + if (is_callable($this->resolver)) { $this->last_result = ($this->resolver)($sql); } @@ -88,7 +91,7 @@ public function query($sql) public function prepare($query, ...$args) { - if (\count($args) === 1 && \is_array($args[0])) { + if (count($args) === 1 && is_array($args[0])) { $args = $args[0]; } @@ -110,6 +113,11 @@ public function get_results($query) { return $this->last_result; } + + public function has_cap($cap) + { + return false; + } } $GLOBALS['wpdb'] = new FakeWpdb(); From 91845399f363a0bbf7b574b6a32494d93c6ca71a Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Tue, 30 Jun 2026 10:39:25 +0600 Subject: [PATCH 063/102] fix: direct-call schema drop helpers emit full SQL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract DROP-clause builders and a shared applyDropClause() dispatcher so dropPrimary, dropTimestamps, dropIndex, dropUnique, and dropForeign emit a complete ALTER TABLE … DROP … statement when called directly (e.g. Schema::dropPrimary('orders')) instead of an empty header. Edit-mode aggregation via Schema::edit() is unchanged. Remove the now- invalid Limitations docs that documented these as edit-only helpers. Assisted-By: AI --- docs/schema.md | 12 ------ src/Blueprint.php | 99 ++++++++++++++++++++------------------------ tests/SchemaTest.php | 72 ++++++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 67 deletions(-) diff --git a/docs/schema.md b/docs/schema.md index ff2581a..d624073 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -267,12 +267,6 @@ Schema::edit('orders', function ($table) { ### Drop helpers -Only `dropColumn` and `renameColumn` produce complete SQL when called as a direct static -call (e.g. `Schema::dropColumn('orders', 'legacy_notes')`). The remaining helpers — -`dropTimestamps`, `dropIndex`, `dropUnique`, `dropForeign`, and `dropPrimary` — must be -used inside a `Schema::edit()` callback; a direct static call only emits the -`ALTER TABLE` header with no `DROP` clause. - | Method | Emitted SQL | |---|---| | `dropColumn($column)` | `DROP $column` | @@ -329,9 +323,3 @@ Schema::withPrefix('custom_')->create('orders', function ($table) { `null`, not `''`; no prefix is prepended unless you call `Schema::withPrefix()` first. To use the WordPress prefix, pass it explicitly: `Schema::withPrefix($wpdb->prefix)->create(...)`. - -- **`dropTimestamps()`, `dropIndex()`, `dropUnique()`, `dropForeign()`, `dropPrimary()` - must be used inside a `Schema::edit()` callback.** A direct static call (e.g. - `Schema::dropTimestamps('orders')`) only sets the `ALTER TABLE` header and emits no - `DROP` clause, producing a syntax error. Only `dropColumn()` and `renameColumn()` - produce complete SQL when called directly. diff --git a/src/Blueprint.php b/src/Blueprint.php index 4e0ea45..3186b77 100644 --- a/src/Blueprint.php +++ b/src/Blueprint.php @@ -499,83 +499,35 @@ public function cascade() public function dropForeign($keys) { - if ($this->method === 'dropForeign') { - $this->_sql = "ALTER TABLE `{$this->table}`"; - } else { - $sql = ''; - $idCount = \count($keys) - 1; - $i = 0; - if (\is_array($keys)) { - foreach ($keys as $key) { - if ($i == $idCount) { - $sql .= " DROP FOREIGN KEY `{$key}`"; - } else { - $sql .= " DROP FOREIGN KEY `{$key}`,"; - } - - $i++; - } - } else { - $sql .= " DROP FOREIGN KEY `{$keys}`"; - } - - $this->_edit['dropForeign'] = $sql; - } + $this->applyDropClause('dropForeign', 'dropForeign', $this->buildDropForeignClause($keys)); return $this; } public function dropIndex($indexes) { - if ($this->method === 'dropIndex') { - $this->_sql = "ALTER TABLE `{$this->table}`"; - } else { - $sql = ''; - $idCount = \count($indexes) - 1; - $i = 0; - if (\is_array($indexes)) { - foreach ($indexes as $index) { - if ($i == $idCount) { - $sql .= " DROP INDEX `{$index}`"; - } else { - $sql .= " DROP INDEX `{$index}`,"; - } - - $i++; - } - } else { - $sql .= " DROP INDEX `{$indexes}`"; - } - - $this->_edit['dropIndex'] = $sql; - } + $this->applyDropClause('dropIndex', 'dropIndex', $this->buildDropIndexClause($indexes)); return $this; } public function dropPrimary() { - if ($this->method === 'dropPrimary') { - $this->_sql = "ALTER TABLE `{$this->table}`"; - } else { - $this->_edit['dropPrimary'] = ' DROP PRIMARY KEY'; - } + $this->applyDropClause('dropPrimary', 'dropPrimary', ' DROP PRIMARY KEY'); return $this; } public function dropUnique($indexes) { - return $this->dropIndex($indexes); + $this->applyDropClause('dropUnique', 'dropIndex', $this->buildDropIndexClause($indexes)); + + return $this; } public function dropTimestamps() { - if ($this->method === 'dropTimestamps') { - $this->_sql = "ALTER TABLE `{$this->table}`"; - } else { - $this->_edit['dropTimestamps'] = ' DROP COLUMN created_at, DROP COLUMN updated_at'; - } + $this->applyDropClause('dropTimestamps', 'dropTimestamps', ' DROP COLUMN created_at, DROP COLUMN updated_at'); return $this; } @@ -596,6 +548,43 @@ public function length($length) return $this; } + private function buildDropIndexClause($indexes) + { + if (\is_array($indexes)) { + $clauses = []; + foreach ($indexes as $index) { + $clauses[] = " DROP INDEX `{$index}`"; + } + + return implode(',', $clauses); + } + + return " DROP INDEX `{$indexes}`"; + } + + private function buildDropForeignClause($keys) + { + if (\is_array($keys)) { + $clauses = []; + foreach ($keys as $key) { + $clauses[] = " DROP FOREIGN KEY `{$key}`"; + } + + return implode(',', $clauses); + } + + return " DROP FOREIGN KEY `{$keys}`"; + } + + private function applyDropClause($directMethod, $editKey, $clause) + { + if ($this->method === $directMethod) { + $this->_sql = "ALTER TABLE `{$this->table}`" . $clause; + } else { + $this->_edit[$editKey] = $clause; + } + } + private function getCollation() { $collate = null; diff --git a/tests/SchemaTest.php b/tests/SchemaTest.php index 3437c1d..f3a8096 100644 --- a/tests/SchemaTest.php +++ b/tests/SchemaTest.php @@ -11,6 +11,7 @@ * * These tests operate on the literal table name — no prefix is applied because * Connection::setPluginPrefix is intentionally not called here. + * */ final class SchemaTest extends TestCase { @@ -46,4 +47,75 @@ public function testNonChangedEditEmitsAddColumn(): void $this->assertStringContainsString('ADD COLUMN note VARCHAR(64)', $sql); } + + public function testDropPrimaryDirectCallEmitsFullSql(): void + { + Schema::dropPrimary('orders'); + + $sql = $GLOBALS['wpdb']->last_query; + + $this->assertStringContainsString('ALTER TABLE `orders`', $sql); + $this->assertStringContainsString('DROP PRIMARY KEY', $sql); + } + + public function testDropTimestampsDirectCallEmitsFullSql(): void + { + Schema::dropTimestamps('orders'); + + $sql = $GLOBALS['wpdb']->last_query; + + $this->assertStringContainsString('ALTER TABLE `orders`', $sql); + $this->assertStringContainsString('DROP COLUMN created_at, DROP COLUMN updated_at', $sql); + } + + public function testDropIndexDirectCallSingleNameEmitsFullSql(): void + { + Schema::dropIndex('orders', 'email_INDEX'); + + $sql = $GLOBALS['wpdb']->last_query; + + $this->assertStringContainsString('ALTER TABLE `orders`', $sql); + $this->assertStringContainsString('DROP INDEX `email_INDEX`', $sql); + } + + public function testDropIndexDirectCallArrayNamesEmitsFullSql(): void + { + Schema::dropIndex('orders', ['a', 'b']); + + $sql = $GLOBALS['wpdb']->last_query; + + $this->assertStringContainsString('DROP INDEX `a`', $sql); + $this->assertStringContainsString('DROP INDEX `b`', $sql); + } + + public function testDropUniqueDirectCallEmitsDropIndexSql(): void + { + Schema::dropUnique('orders', 'email_UNIQUE'); + + $sql = $GLOBALS['wpdb']->last_query; + + $this->assertStringContainsString('ALTER TABLE `orders`', $sql); + $this->assertStringContainsString('DROP INDEX `email_UNIQUE`', $sql); + } + + public function testDropForeignDirectCallEmitsFullSql(): void + { + Schema::dropForeign('orders', 'fk_user'); + + $sql = $GLOBALS['wpdb']->last_query; + + $this->assertStringContainsString('ALTER TABLE `orders`', $sql); + $this->assertStringContainsString('DROP FOREIGN KEY `fk_user`', $sql); + } + + public function testDropPrimaryEditModeStillWorks(): void + { + Schema::edit('orders', function ($table) { + $table->dropPrimary(); + }); + + $sql = $GLOBALS['wpdb']->last_query; + + $this->assertStringContainsString('DROP PRIMARY KEY', $sql); + } } From b37aaa7002057d7684986e17fbd4d9b5c14ed97f Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Tue, 30 Jun 2026 10:44:59 +0600 Subject: [PATCH 064/102] feat: subscribable creating/created model events Assisted-By: AI --- docs/usage.md | 12 +-------- src/Concerns/HasEvents.php | 10 +++++++ tests/CreatingCreatedEventTest.php | 43 ++++++++++++++++++++++++++++++ tests/Fixtures/CreatingUser.php | 37 +++++++++++++++++++++++++ tests/bootstrap.php | 1 + 5 files changed, 92 insertions(+), 11 deletions(-) create mode 100644 tests/CreatingCreatedEventTest.php create mode 100644 tests/Fixtures/CreatingUser.php diff --git a/docs/usage.md b/docs/usage.md index cf46489..7285e43 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -565,17 +565,13 @@ class Contact extends Model ``` **Subscribable events** (registered via Closure using the methods below): -`retrieved`, `saving`, `saved`, `updating`, `updated`, `deleting`, `deleted`. +`retrieved`, `creating`, `created`, `saving`, `saved`, `updating`, `updated`, `deleting`, `deleted`. **Boot hooks** — override these protected static methods instead of registering a Closure: `booting()` (runs before `boot()`), `booted()` (runs after `boot()`). The `boot()` method itself is the standard entry point for registering event handlers. -> `creating`/`created` events fire internally but have no public registrar — -> they cannot be subscribed via `static::creating(...)` / `static::created(...)`. -> Use `saving`/`saved` instead, which fire on both insert and update. - > The `HasEvents` trait reserves the method names `boot`, `booting`, `booted`, > `fireEvent`, `fireCustomEvent`, `registerEvent` and the properties `$events`, > `$registeredEvents`, `$booted`. @@ -700,12 +696,6 @@ method relocation. timestamp may be wrong on update. Workaround: use separate `insert` + `update` calls where portability or correct timestamps are required. -- **`creating`/`created` events cannot be subscribed.** These event names fire - internally during `insert()` but `HasEvents` provides no registrar methods for - them. Calling `static::creating(fn ...)` or `static::created(fn ...)` in - `boot()` will throw a fatal error. Use `saving`/`saved` instead — they fire - on both insert and update. - - **Bulk `insert()` may return a bare array, not a `Collection`.** When the post-insert re-query that hydrates the inserted rows fails, the fallback path returns a plain PHP array of IDs rather than a `Collection`. Code that calls diff --git a/src/Concerns/HasEvents.php b/src/Concerns/HasEvents.php index 80cdf52..4594ae9 100644 --- a/src/Concerns/HasEvents.php +++ b/src/Concerns/HasEvents.php @@ -50,6 +50,16 @@ protected static function retrieved(Closure $callback) static::registerEvent('retrieved', $callback); } + protected static function creating(Closure $callback) + { + static::registerEvent('creating', $callback); + } + + protected static function created(Closure $callback) + { + static::registerEvent('created', $callback); + } + protected static function saving(Closure $callback) { static::registerEvent('saving', $callback); diff --git a/tests/CreatingCreatedEventTest.php b/tests/CreatingCreatedEventTest.php new file mode 100644 index 0000000..a3dd325 --- /dev/null +++ b/tests/CreatingCreatedEventTest.php @@ -0,0 +1,43 @@ +insert_id = 1; + + CreatingUser::insert(['name' => 'Ada']); + + $this->assertTrue(CreatingUser::$creatingCalled, 'creating handler should have run'); + $this->assertTrue(CreatingUser::$createdCalled, 'created handler should have run'); + } + + public function testCreatingReturningFalseAbortsInsert(): void + { + CreatingUser::$abortCreating = true; + + $result = CreatingUser::insert(['name' => 'Ada']); + + $this->assertSame([], $GLOBALS['wpdb']->queries, 'aborted insert must not execute any query'); + $this->assertFalse($result, 'aborted insert returns false'); + } +} diff --git a/tests/Fixtures/CreatingUser.php b/tests/Fixtures/CreatingUser.php new file mode 100644 index 0000000..b8ed830 --- /dev/null +++ b/tests/Fixtures/CreatingUser.php @@ -0,0 +1,37 @@ + Date: Tue, 30 Jun 2026 10:54:37 +0600 Subject: [PATCH 065/102] fix: bulk insert always returns a Collection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fallback path in QueryBuilder::bulkInsert() — triggered when the post-insert re-query hydrates nothing — returned a bare int[] instead of a Collection, breaking the documented contract. Wrap $ids in new Collection($ids) so callers receive a consistent Collection regardless of path. Element-type caveat: Collection elements are Model instances on the happy path (re-query succeeded) and ints (inserted IDs) on the fallback. foreach/count() are unaffected; is_array()/empty() now return false on the fallback. Docs: remove the Limitations bullet that documented the bug; update the bulk-insert code comment to note the element-type distinction. Assisted-By: AI --- docs/usage.md | 11 ++----- src/QueryBuilder.php | 2 +- tests/BulkInsertReturnTest.php | 53 ++++++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 9 deletions(-) create mode 100644 tests/BulkInsertReturnTest.php diff --git a/docs/usage.md b/docs/usage.md index 7285e43..59d065d 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -142,7 +142,9 @@ $contact = Contact::insert([ 'email' => 'ada@example.com', ]); -// Bulk insert (array of rows) — returns a Collection of created models +// Bulk insert (array of rows) — always returns a Collection. +// Element type: Model on success (re-query hydrated); int (inserted ID) on +// the rare fallback where the post-insert re-query yields no rows. $created = Contact::insert([ ['first_name' => 'Ada', 'email' => 'ada@x.com'], ['first_name' => 'Grace','email' => 'grace@x.com'], @@ -696,13 +698,6 @@ method relocation. timestamp may be wrong on update. Workaround: use separate `insert` + `update` calls where portability or correct timestamps are required. -- **Bulk `insert()` may return a bare array, not a `Collection`.** When the - post-insert re-query that hydrates the inserted rows fails, the fallback path - returns a plain PHP array of IDs rather than a `Collection`. Code that calls - Collection methods on the return value of `insert()` will break in that case. - Workaround: check `is_array()` on the result or use `Collection::make()` to - wrap it defensively. - - **Schema builder does not auto-apply the table prefix.** `Schema::$prefix` defaults to `null`, not `''`; table names are used as-is unless you call `Schema::withPrefix()` explicitly. See [Schema builder reference](schema.md). diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index 43fde14..093ab9c 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -1505,7 +1505,7 @@ function ($value) { return $allRows; } - return $ids; + return new Collection($ids); } return false; diff --git a/tests/BulkInsertReturnTest.php b/tests/BulkInsertReturnTest.php new file mode 100644 index 0000000..b900f48 --- /dev/null +++ b/tests/BulkInsertReturnTest.php @@ -0,0 +1,53 @@ +rows_affected = 2; + $GLOBALS['wpdb']->insert_id = 10; + // last_result defaults to [] — get() returns [], triggering the fallback path. + + $result = User::query()->insert([['name' => 'a'], ['name' => 'b']]); + + $this->assertInstanceOf(Collection::class, $result); + } + + public function testBulkInsertHappyPathReturnsCollection(): void + { + // Happy path: INSERT succeeds and re-query hydrates the rows into Models. + $GLOBALS['wpdb']->rows_affected = 2; + $GLOBALS['wpdb']->insert_id = 10; + $GLOBALS['wpdb']->queueResult([ + (object) ['id' => 10, 'name' => 'a'], + (object) ['id' => 11, 'name' => 'b'], + ]); + + $result = User::query()->insert([['name' => 'a'], ['name' => 'b']]); + + $this->assertInstanceOf(Collection::class, $result); + } +} From 3e5db3ab606534b69ba91a013446eae661afc552 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Tue, 30 Jun 2026 10:59:02 +0600 Subject: [PATCH 066/102] fix: joins no longer double-prefix and qualify ON column Assisted-By: AI --- docs/usage.md | 14 -------------- src/QueryBuilder.php | 30 ++++++++++++++++-------------- tests/Query/GrammarTest.php | 15 ++++++++++++++- 3 files changed, 30 insertions(+), 29 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index 59d065d..cd11c5b 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -268,12 +268,6 @@ Contact::query() // also: rightJoin(), fullJoin(), crossJoin(), on(), orOn() ``` -> **Joins are currently unreliable.** The join implementation has known bugs: -> the joined table name is double-prefixed (`wp_wp_*`), ON-clause columns are not -> adjusted when an alias is present, and `prepareOn` reuses the mutated column -> value for the second-column lookup. Use raw SQL (`whereRaw` / `raw()`) until -> these are fixed. See [Limitations](#limitations--known-issues). - ### Limit / offset / pagination ```php @@ -668,14 +662,6 @@ method relocation. ## Limitations & known issues -- **Joins are broadly unreliable.** `join()` in `QueryBuilder` always prepends - `Connection::wpPrefix()` (plus the model prefix) onto the supplied table name, - causing a double prefix (`wp_wp_*`) unconditionally — not only when a plugin - prefix is set. Separately, `prepareOn()` reuses the mutated column value for the - second-column lookup, and ON-clause columns are not adjusted when an alias is - present. Workaround: write raw JOIN clauses via `raw()` and apply your own - prefix via `Connection::getPrefix()`. - - **`belongsToMany` is declared but non-functional.** The method exists but contains no pivot-table join logic. Calling it does not produce a many-to-many query through an intermediate table. Workaround: model the pivot diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index 093ab9c..f31cdae 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -705,18 +705,17 @@ public function orHaving(...$params) */ public function join($table, $firstColumn, $operator = null, $secondColumn = null, $type = 'INNER') { - $table = Connection::wpPrefix() . $this->_model->getPrefix() . $table; - $hasAlias = preg_split('/ as /i', $table); - if ($hasAlias && isset($hasAlias[1])) { - $table = $hasAlias[0]; - $alias = $hasAlias[1]; - } else { - $alias = $table; - } - - $on[] = $this->prepareOn($alias, $firstColumn, $operator, $secondColumn, 'AND'); + $parts = preg_split('/ as /i', $table); + $rawTable = $parts[0]; + $alias = isset($parts[1]) ? $parts[1] : null; + $prefixedTable = $this->_model->getPrefix() . $rawTable; + $reference = $alias !== null ? $alias : $prefixedTable; + $tableSql = $alias !== null ? $prefixedTable . ' as ' . $alias : $prefixedTable; + + $on[] = $this->prepareOn($reference, $firstColumn, $operator, $secondColumn, 'AND'); $this->joins[] = [ - 'table' => $table, + 'table' => $tableSql, + 'alias' => $reference, 'on' => $on, 'type' => $type, ]; @@ -801,7 +800,7 @@ public function on($firstColumn, $operator = null, $secondColumn = null, $bool = $joinIndex = 0; } - $table = $this->joins[$joinIndex]['table']; + $table = $this->joins[$joinIndex]['alias']; $this->joins[$joinIndex]['on'][] = $this->prepareOn($table, $firstColumn, $operator, $secondColumn, $bool); return $this; @@ -1393,11 +1392,14 @@ protected function prepareHaving($params, $bool = 'AND') protected function prepareOn($table, $column, $operator, $secondColumn, $bool = 'AND') { if (\is_null($operator) && \is_null($secondColumn)) { - $column = $this->_model->getTable() . '.' . $column; - $secondColumn = $table . '.' . $column; + $secondColumn = $column; $operator = '='; } + if (!\is_null($secondColumn) && strpos($secondColumn, '.') === false) { + $secondColumn = $table . '.' . $secondColumn; + } + return compact('column', 'operator', 'secondColumn', 'bool'); } diff --git a/tests/Query/GrammarTest.php b/tests/Query/GrammarTest.php index dc34f38..f72c60d 100644 --- a/tests/Query/GrammarTest.php +++ b/tests/Query/GrammarTest.php @@ -72,11 +72,24 @@ public function testNestedClosureWhereProducesParenthesizedGroup(): void public function testJoin(): void { $this->assertSame( - 'SELECT FROM wp_users INNER JOIN wp_wp_posts ON `wp_users`.`user_id` = id', + 'SELECT FROM wp_users INNER JOIN wp_posts ON `wp_users`.`user_id` = wp_posts.id', (new User())->join('posts', 'user_id', '=', 'id')->toSql() ); } + public function testJoinWithAlias(): void + { + $sql = (new User())->join('posts as p', 'user_id', '=', 'id')->toSql(); + $this->assertStringContainsString('INNER JOIN wp_posts as p ON', $sql); + $this->assertStringContainsString('= p.id', $sql); + } + + public function testJoinWithDottedColumnsUntouched(): void + { + $sql = (new User())->join('posts', 'posts.user_id', '=', 'users.id')->toSql(); + $this->assertStringContainsString('= users.id', $sql); + } + public function testGroupBy(): void { $this->assertSame( From 0e4b29afc2b33d066ce93750d3d3d458d3a5ce95 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Tue, 30 Jun 2026 11:03:53 +0600 Subject: [PATCH 067/102] feat: add Schema::withWpPrefix() opt-in for WP-prefixed tables Adds a real static method Schema::withWpPrefix() that sets the Schema prefix to Connection::getPrefix() (wp_ + plugin prefix), giving authors a zero-BC way to create tables that match Model table names. Default behaviour (bare table name) is unchanged: Schema::create('orders') still emits CREATE TABLE IF NOT EXISTS orders. The BC guard test locks this. Docs: reframe the prefix Limitations bullet in usage.md and schema.md from "does not auto-apply" to "by design bare", and document the new method in the Table operations table with a code example. Assisted-By: AI --- docs/schema.md | 22 ++++++++++++++++------ docs/usage.md | 15 ++++++++++----- src/Schema.php | 8 ++++++++ tests/SchemaTest.php | 37 ++++++++++++++++++++++++++++++++++++- 4 files changed, 70 insertions(+), 12 deletions(-) diff --git a/docs/schema.md b/docs/schema.md index d624073..6d0c477 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -304,22 +304,32 @@ on `(new Schema())->create(...)` — both are equivalent thanks to `__callStatic | `Schema::drop($table)` | `DROP TABLE IF EXISTS`. | | `Schema::rename($table, $newName)` | `ALTER TABLE … RENAME TO`. Both names are used as-is unless `Schema::withPrefix()` is used (no prefix by default). | | `Schema::withPrefix($prefix)` | Sets the prefix applied for this call (there is no prefix by default). Returns a `Schema` instance; chain `.create()`, `.edit()`, etc. | +| `Schema::withWpPrefix()` | Sets the prefix to `Connection::getPrefix()` (WordPress prefix + plugin prefix), so the table matches what `Model`s use. Returns a `Schema` instance; chain `.create()`, `.edit()`, etc. | ```php -// Override prefix — table resolves as "custom_orders" -// (without withPrefix, the bare name "orders" is used — no prefix is applied automatically) +// Literal prefix — table resolves as "custom_orders" +// (without withPrefix/withWpPrefix, the bare name "orders" is used — no prefix is applied) Schema::withPrefix('custom_')->create('orders', function ($table) { $table->id(); $table->string('reference'); $table->timestamps(); }); + +// Match the Model prefix (e.g. wp_ or wp_myplugin_) — table resolves as "wp_orders" +Schema::withWpPrefix()->create('orders', function ($table) { + $table->id(); + $table->string('reference'); + $table->timestamps(); +}); ``` --- ## Limitations & known issues -- **Schema builder does not auto-apply the table prefix.** `Schema::$prefix` defaults to - `null`, not `''`; no prefix is prepended unless you call `Schema::withPrefix()` first. - To use the WordPress prefix, pass it explicitly: - `Schema::withPrefix($wpdb->prefix)->create(...)`. +- **Schema builder uses the literal table name by design.** `Schema::create()` passes the + name through as-is — no WordPress prefix is added automatically, so existing tables are + never relocated. Use `Schema::withPrefix('your_prefix_')` for a literal prefix, or + `Schema::withWpPrefix()` to match the prefix `Model`s use (`Connection::getPrefix()`). + The `$prefix` property intentionally defaults to `null` (not `''`) to preserve + bare-table behaviour for plugins that rely on it. diff --git a/docs/usage.md b/docs/usage.md index cd11c5b..3515777 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -611,8 +611,9 @@ Placeholders use `$wpdb` conventions (`%d`, `%s`, `%f`) with the bindings array. ## Schema builder Use `Schema` (a facade over `Blueprint`) to create, alter and drop tables. By -default the table name is used as-is — call `Schema::withPrefix($prefix)` to -apply a prefix. +design the table name is used as-is — call `Schema::withPrefix($prefix)` to +apply a literal prefix, or `Schema::withWpPrefix()` to use the same prefix +`Model`s use (`Connection::getPrefix()`). ```php use BitApps\WPDatabase\Schema; @@ -684,7 +685,11 @@ method relocation. timestamp may be wrong on update. Workaround: use separate `insert` + `update` calls where portability or correct timestamps are required. -- **Schema builder does not auto-apply the table prefix.** `Schema::$prefix` - defaults to `null`, not `''`; table names are used as-is unless you call - `Schema::withPrefix()` explicitly. See [Schema builder reference](schema.md). +- **Schema builder uses the literal table name by design.** `Schema::create()` passes + the name through as-is — no WordPress prefix is added automatically, so existing + tables are never relocated. Use `Schema::withPrefix('your_prefix_')` for a literal + prefix, or `Schema::withWpPrefix()` to match the prefix `Model`s use + (`Connection::getPrefix()`). The `$prefix` property intentionally defaults to `null` + (not `''`) to preserve bare-table behaviour for plugins that rely on it. + See [Schema builder reference](schema.md). diff --git a/src/Schema.php b/src/Schema.php index ecc9409..6cf2073 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -46,6 +46,14 @@ public function __call($method, $parameters) return $this->build($blueprint); } + public static function withWpPrefix() + { + $schema = new self(); + $schema->prefix = Connection::getPrefix(); + + return $schema; + } + public function createBlueprint($schema, $method, ?Closure $callback = null) { return new Blueprint( diff --git a/tests/SchemaTest.php b/tests/SchemaTest.php index f3a8096..af4756a 100644 --- a/tests/SchemaTest.php +++ b/tests/SchemaTest.php @@ -11,7 +11,6 @@ * * These tests operate on the literal table name — no prefix is applied because * Connection::setPluginPrefix is intentionally not called here. - * */ final class SchemaTest extends TestCase { @@ -118,4 +117,40 @@ public function testDropPrimaryEditModeStillWorks(): void $this->assertStringContainsString('DROP PRIMARY KEY', $sql); } + + // --- B1: prefix behaviour --- + + public function testBareDefaultNeverPrefixedWithWp(): void + { + Schema::create('orders', function ($table) { + $table->id(); + }); + + $sql = $GLOBALS['wpdb']->last_query; + + $this->assertStringContainsString('CREATE TABLE IF NOT EXISTS orders', $sql); + $this->assertStringNotContainsString('wp_orders', $sql); + } + + public function testWithWpPrefixPrependsWpPrefix(): void + { + Schema::withWpPrefix()->create('orders', function ($table) { + $table->id(); + }); + + $sql = $GLOBALS['wpdb']->last_query; + + $this->assertStringContainsString('CREATE TABLE IF NOT EXISTS wp_orders', $sql); + } + + public function testWithPrefixRegressionCustomPrefix(): void + { + Schema::withPrefix('custom_')->create('orders', function ($table) { + $table->id(); + }); + + $sql = $GLOBALS['wpdb']->last_query; + + $this->assertStringContainsString('CREATE TABLE IF NOT EXISTS custom_orders', $sql); + } } From ea54365da9ae8f9ec877449a1611626efa72b3cb Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Tue, 30 Jun 2026 11:14:20 +0600 Subject: [PATCH 068/102] feat: opt-in soft-delete read scope with withTrashed/onlyTrashed Assisted-By: AI --- docs/schema.md | 7 ++- docs/usage.md | 21 ++++++--- src/Model.php | 2 + src/QueryBuilder.php | 67 ++++++++++++++++++++++++++- tests/Fixtures/ScopedSoftPost.php | 18 ++++++++ tests/SoftDeleteScopeTest.php | 75 +++++++++++++++++++++++++++++++ tests/bootstrap.php | 1 + 7 files changed, 181 insertions(+), 10 deletions(-) create mode 100644 tests/Fixtures/ScopedSoftPost.php create mode 100644 tests/SoftDeleteScopeTest.php diff --git a/docs/schema.md b/docs/schema.md index 6d0c477..0c4b801 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -212,8 +212,11 @@ Adds a single nullable `TIMESTAMP` column named `deleted_at`, defaulting to `NUL The model-side `$soft_deletes = true` property (see [Defining models](usage.md#defining-models)) instructs `delete()` to set `deleted_at` -rather than removing the row. **Reads are not filtered** — soft-deleted rows are returned -by `all()` and every query; append `->whereNull('deleted_at')` manually to exclude them. +rather than removing the row. Reads return **all rows by default** — including trashed +ones. To enable automatic filtering, also declare `public $soft_delete_scope = true;` on +the model: reads then exclude trashed rows automatically. Use `->withTrashed()` to +include them, or `->onlyTrashed()` to return only trashed rows. See +[Limitations](usage.md#limitations--known-issues) for the `refresh()` edge case. --- diff --git a/docs/usage.md b/docs/usage.md index 3515777..eaa2a40 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -123,7 +123,7 @@ class Contact extends Model | `$fillable` | Mass-assignment allow-list. Unset = allow all non-timestamp, non-PK attributes. | | `$casts` | Map of column → cast type. See [Attribute casting](#attribute-casting). | | `$timestamps` | Auto-set `created_at`/`updated_at` on insert/update (declared `true` in the base `Model`; set to `false` to disable). Requires the columns to exist — see [`timestamps()` in `docs/schema.md`](schema.md#timestamps). | -| `$soft_deletes` | Must be declared `true` on the model. `delete()` then sets `deleted_at` instead of removing the row. **Reads are not filtered** — soft-deleted rows are returned by `all()` and every query. See [Limitations](#limitations--known-issues) and [`softDeletes()` in `docs/schema.md`](schema.md#softdeletes). | +| `$soft_deletes` | Must be declared `true` on the model. `delete()` then sets `deleted_at` instead of removing the row. Reads return **all rows by default** — including trashed ones. To enable automatic filtering, also declare `public $soft_delete_scope = true;`; reads will then exclude trashed rows automatically. Use `->withTrashed()` to include them, or `->onlyTrashed()` to return only trashed rows. See [Limitations](#limitations--known-issues) and [`softDeletes()` in `docs/schema.md`](schema.md#softdeletes). | --- @@ -361,8 +361,10 @@ Contact::destroy([1, 2, 3]); // delete by primary keys `RuntimeException('SQL query is empty')` — a guard against wiping the table. Use a raw `TRUNCATE` if you really mean to empty it. - With `$soft_deletes = true`, `delete()` sets `deleted_at` instead of removing - the row. **Reads are not filtered** — soft-deleted rows are returned by `all()` - and every query; there is no automatic scope. See [Limitations](#limitations--known-issues). + the row. Reads return **all rows by default** — including trashed ones. Add + `public $soft_delete_scope = true;` to enable automatic filtering: reads exclude + trashed rows, `->withTrashed()` includes them, `->onlyTrashed()` returns only + trashed rows. See [Limitations](#limitations--known-issues). --- @@ -674,10 +676,15 @@ method relocation. **calling** model's table — the opposite of Laravel's convention. Ensure you supply both arguments explicitly to avoid confusion. -- **Soft delete is write-only.** `softDeletes()` adds a `deleted_at` column and - `delete()` sets it, but there is no global scope to filter soft-deleted rows - from queries. Every `get()` / `find()` returns soft-deleted rows alongside - live ones. Workaround: add `->whereNull('deleted_at')` to every read query. +- **Soft delete reads are unfiltered by default.** `softDeletes()` adds a + `deleted_at` column and `delete()` sets it. Reads return all rows — including + trashed ones — unless you also declare `public $soft_delete_scope = true;` on + the model. With the flag set, reads automatically exclude trashed rows; + `->withTrashed()` includes them and `->onlyTrashed()` returns only trashed rows. + **Edge case:** `refresh()` / `exists()` use a default (now scoped) read, so a + trashed opt-in model reloaded without `->withTrashed()` reports `exists() === false` + and a subsequent `save()` will INSERT rather than UPDATE. Follow-up planned to + thread soft-delete awareness into `refresh()`. - **`upsert` is MySQL-only.** It generates `INSERT … ON DUPLICATE KEY UPDATE`, which is not portable to other databases. Additionally, the generated SQL sets diff --git a/src/Model.php b/src/Model.php index 012e1da..a6f4d57 100644 --- a/src/Model.php +++ b/src/Model.php @@ -87,6 +87,8 @@ * @method static bool|string delete() * @method static string toSql() * @method static string prepare($sql = null) + * @method static QueryBuilder withTrashed() + * @method static QueryBuilder onlyTrashed() * * @mixin \BitApps\WPDatabase\QueryBuilder */ diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index f31cdae..c171637 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -77,6 +77,10 @@ class QueryBuilder private $_grammar; + private $_withTrashed = false; + + private $_onlyTrashed = false; + /** * Constructs QueryBuilder * @@ -240,13 +244,30 @@ public function getTable() /** * Returns the clause list (where/having) for the given type. * + * When the type is 'where' and the model opts into soft-delete scope, + * a deleted_at IS NULL (or IS NOT NULL for onlyTrashed) clause is injected + * on SELECT queries without mutating $this->where. + * * @param string $type * * @return array */ public function getClauseList($type) { - return $type === 'having' ? $this->having : $this->where; + if ($type === 'having') { + return $this->having; + } + + $where = $this->where; + if ($this->isSoftDeleteModel() && $this->_method === self::SELECT) { + if ($this->_onlyTrashed) { + $where[] = ['column' => 'deleted_at', 'operator' => 'IS NOT NULL']; + } elseif ($this->autoScopeEnabled() && !$this->_withTrashed) { + $where[] = ['column' => 'deleted_at', 'operator' => 'IS NULL']; + } + } + + return $where; } /** @@ -579,6 +600,30 @@ public function whereNotNull($column) return $this; } + /** + * Include soft-deleted rows in the result set. + * + * @return $this + */ + public function withTrashed() + { + $this->_withTrashed = true; + + return $this; + } + + /** + * Restrict results to only soft-deleted rows. + * + * @return $this + */ + public function onlyTrashed() + { + $this->_onlyTrashed = true; + + return $this; + } + /** * Set where clause with between condition * @@ -1438,6 +1483,26 @@ protected function getTimeZone() return $timezoneString; } + /** + * Returns true when the model declares soft-delete support. + * + * @return bool + */ + private function isSoftDeleteModel() + { + return property_exists($this->_model, 'soft_deletes') && $this->_model->soft_deletes; + } + + /** + * Returns true when the model opts into automatic soft-delete read scope. + * + * @return bool + */ + private function autoScopeEnabled() + { + return property_exists($this->_model, 'soft_delete_scope') && $this->_model->soft_delete_scope; + } + /** * Run bulk insert query * diff --git a/tests/Fixtures/ScopedSoftPost.php b/tests/Fixtures/ScopedSoftPost.php new file mode 100644 index 0000000..104d15d --- /dev/null +++ b/tests/Fixtures/ScopedSoftPost.php @@ -0,0 +1,18 @@ +toSql(); + $this->assertStringNotContainsString('deleted_at', $sql); + } + + // Non-soft-delete model untouched + public function testNonSoftDeleteModelIsNotFiltered(): void + { + $sql = User::query()->toSql(); + $this->assertStringNotContainsString('deleted_at', $sql); + } + + // Opt-in model: default query filters out trashed rows + public function testScopedModelDefaultQueryFiltersDeletedAt(): void + { + $sql = ScopedSoftPost::query()->toSql(); + $this->assertStringContainsString('deleted_at', $sql); + $this->assertStringContainsString('IS NULL', $sql); + } + + // withTrashed() removes the filter + public function testWithTrashedRemovesDeletedAtFilter(): void + { + $sql = ScopedSoftPost::query()->withTrashed()->toSql(); + $this->assertStringNotContainsString('deleted_at', $sql); + } + + // onlyTrashed() returns only trashed rows + public function testOnlyTrashedFiltersToTrashedRows(): void + { + $sql = ScopedSoftPost::query()->onlyTrashed()->toSql(); + $this->assertStringContainsString('deleted_at', $sql); + $this->assertStringContainsString('IS NOT NULL', $sql); + } + + // onlyTrashed() works on a non-scoped $soft_deletes model too + public function testOnlyTrashedWorksWithoutScopeFlag(): void + { + $sql = SoftPost::query()->onlyTrashed()->toSql(); + $this->assertStringContainsString('IS NOT NULL', $sql); + } + + // Aggregate carries the scope + public function testAggregateCarriesSoftDeleteScope(): void + { + ScopedSoftPost::query()->count(); + $sql = $GLOBALS['wpdb']->last_query; + $this->assertStringContainsString('deleted_at', $sql); + $this->assertStringContainsString('IS NULL', $sql); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 517bb5c..c270bc7 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -131,3 +131,4 @@ public function has_cap($cap) require __DIR__ . '/Fixtures/CastModel.php'; require __DIR__ . '/Fixtures/AccessorModel.php'; require __DIR__ . '/Fixtures/CreatingUser.php'; +require __DIR__ . '/Fixtures/ScopedSoftPost.php'; From e78915e0a08637e849f2de1c273370afa06e729c Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Tue, 30 Jun 2026 11:24:22 +0600 Subject: [PATCH 069/102] fix: parenthesize user WHERE when injecting soft-delete scope Prevents AND/OR precedence leak when orWhere is combined with the auto-scope or onlyTrashed, which could expose trashed rows. Assisted-By: AI --- src/QueryBuilder.php | 33 +++++++++++++++++++++++++-------- tests/SoftDeleteScopeTest.php | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 8 deletions(-) diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index c171637..bc41d9d 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -258,16 +258,33 @@ public function getClauseList($type) return $this->having; } - $where = $this->where; - if ($this->isSoftDeleteModel() && $this->_method === self::SELECT) { - if ($this->_onlyTrashed) { - $where[] = ['column' => 'deleted_at', 'operator' => 'IS NOT NULL']; - } elseif ($this->autoScopeEnabled() && !$this->_withTrashed) { - $where[] = ['column' => 'deleted_at', 'operator' => 'IS NULL']; - } + if (!$this->isSoftDeleteModel() || $this->_method !== self::SELECT) { + return $this->where; + } + + $scopeClause = null; + if ($this->_onlyTrashed) { + $scopeClause = ['column' => 'deleted_at', 'operator' => 'IS NOT NULL']; + } elseif ($this->autoScopeEnabled() && !$this->_withTrashed) { + $scopeClause = ['column' => 'deleted_at', 'operator' => 'IS NULL']; + } + + if ($scopeClause === null) { + return $this->where; } - return $where; + if (empty($this->where)) { + return [$scopeClause]; + } + + // Wrap user conditions to prevent AND/OR precedence issues with the injected scope + $nestedQuery = $this->newQuery(); + $nestedQuery->where = $this->where; + + return [ + ['query' => $nestedQuery], + $scopeClause, + ]; } /** diff --git a/tests/SoftDeleteScopeTest.php b/tests/SoftDeleteScopeTest.php index 4173d57..e73b86e 100644 --- a/tests/SoftDeleteScopeTest.php +++ b/tests/SoftDeleteScopeTest.php @@ -72,4 +72,36 @@ public function testAggregateCarriesSoftDeleteScope(): void $this->assertStringContainsString('deleted_at', $sql); $this->assertStringContainsString('IS NULL', $sql); } + + // scope + orWhere: user conditions must be parenthesized to prevent precedence leak + public function testScopedModelOrWhereGroupsUserConditions(): void + { + $sql = ScopedSoftPost::query() + ->where('status', 'active') + ->orWhere('status', 'pending') + ->toSql(); + + // scope clause must be outside the OR group + $this->assertStringContainsString('deleted_at', $sql); + $this->assertStringContainsString('IS NULL', $sql); + // user conditions must be parenthesized + $this->assertMatchesRegularExpression('/\(.*status.*OR.*status.*\)/s', $sql); + // scope must appear after the closing parenthesis (column may be backtick-quoted) + $this->assertMatchesRegularExpression('/\).*deleted_at.*IS\s+NULL/s', $sql); + } + + // onlyTrashed + orWhere: same grouping requirement + public function testOnlyTrashedOrWhereGroupsUserConditions(): void + { + $sql = ScopedSoftPost::query() + ->where('status', 'active') + ->orWhere('status', 'pending') + ->onlyTrashed() + ->toSql(); + + $this->assertStringContainsString('deleted_at', $sql); + $this->assertStringContainsString('IS NOT NULL', $sql); + $this->assertMatchesRegularExpression('/\(.*status.*OR.*status.*\)/s', $sql); + $this->assertMatchesRegularExpression('/\).*deleted_at.*IS\s+NOT\s+NULL/s', $sql); + } } From 9677449224aec788a5a5eded73ea1c92ab3c5283 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Tue, 30 Jun 2026 12:10:26 +0600 Subject: [PATCH 070/102] fix: upsert manages updated_at; reorganise review-fix tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit upsert() on a $timestamps model now inserts both created_at and updated_at and, on a duplicate key, bumps updated_at (VALUES(updated_at)) while preserving the original created_at — replacing the prior logic that left updated_at unmanaged and emitted the `updated_at = VALUES(created_at)` swap. No timestamp magic when $timestamps is false. Splits the non-generic GeminiReviewFixesTest into topical suites — UpsertTest, AttributeCastTest, CollectionTest — and removes the old upsert-swap test that locked the previous mapping. Reframes the upsert docs and records the behavioural change in breaking-changes §3. Assisted-By: AI --- docs/breaking-changes.md | 6 +++ docs/usage.md | 18 +++----- src/QueryBuilder.php | 32 ++++++++----- tests/AttributeCastTest.php | 44 ++++++++++++++++++ tests/CollectionTest.php | 35 +++++++++++++++ tests/Fixtures/TimestampedRow.php | 18 ++++++++ tests/GeminiReviewFixesTest.php | 74 ------------------------------ tests/UpsertTest.php | 75 +++++++++++++++++++++++++++++++ tests/bootstrap.php | 1 + 9 files changed, 207 insertions(+), 96 deletions(-) create mode 100644 tests/AttributeCastTest.php create mode 100644 tests/CollectionTest.php create mode 100644 tests/Fixtures/TimestampedRow.php delete mode 100644 tests/GeminiReviewFixesTest.php create mode 100644 tests/UpsertTest.php diff --git a/docs/breaking-changes.md b/docs/breaking-changes.md index 2391de2..c180c7e 100644 --- a/docs/breaking-changes.md +++ b/docs/breaking-changes.md @@ -303,6 +303,12 @@ Not signature breaks, but observable runtime differences. - **Internal layout:** the three traits moved to `BitApps\WPDatabase\Concerns` and SELECT compilation extracted to `BitApps\WPDatabase\Query\Grammar`. Public classes are unchanged; only code importing those internals directly is affected. +- **`upsert()` now manages `updated_at`** for `$timestamps` models: it inserts both + `created_at` and `updated_at`, and on a duplicate key bumps + `updated_at = VALUES(updated_at)` while preserving `created_at` — replacing the + prior behavior that left `updated_at` untouched and mapped + `updated_at = VALUES(created_at)`. The generated SQL changes for upsert on + timestamped models. --- diff --git a/docs/usage.md b/docs/usage.md index eaa2a40..3deb44d 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -387,13 +387,11 @@ Contact::query()->upsert([ ``` > **MySQL only** — `upsert()` emits `INSERT … ON DUPLICATE KEY UPDATE` and will not -> work on other databases. -> -> **`updated_at` quirk** — when `updated_at` is in the conflict-update set, the -> generated SQL maps `updated_at = VALUES(created_at)` rather than -> `VALUES(updated_at)`. This is a known implementation detail; the timestamp will -> reflect the value written to `created_at` at insert time, not a separate -> `updated_at` value. See [Limitations](#limitations--known-issues). +> work on other databases. See [Limitations](#limitations--known-issues). + +On a model with `$timestamps = true`, `upsert()` sets both `created_at` and +`updated_at` on insert, and on a duplicate key it bumps `updated_at` +(`updated_at = VALUES(updated_at)`) while preserving the original `created_at`. --- @@ -687,10 +685,8 @@ method relocation. thread soft-delete awareness into `refresh()`. - **`upsert` is MySQL-only.** It generates `INSERT … ON DUPLICATE KEY UPDATE`, - which is not portable to other databases. Additionally, the generated SQL sets - `updated_at = VALUES(created_at)` instead of `VALUES(updated_at)`, so the - timestamp may be wrong on update. Workaround: use separate `insert` + `update` - calls where portability or correct timestamps are required. + which is not portable to other databases. Workaround: use separate `insert` + + `update` calls where cross-database portability is required. - **Schema builder uses the literal table name by design.** `Schema::create()` passes the name through as-is — no WordPress prefix is added automatically, so existing diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index bc41d9d..cafb12e 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -1280,10 +1280,15 @@ public function upsert(array $values, ?array $update = null) $this->bindings = []; $columns = array_keys($values[0]); sort($columns); - $createdAt = property_exists($this->_model, 'timestamps') && $this->_model->timestamps && !\in_array('created_at', $columns); - if ($createdAt) { + $manageTimestamps = property_exists($this->_model, 'timestamps') && $this->_model->timestamps; + $addCreatedAt = $manageTimestamps && !\in_array('created_at', $columns, true); + $addUpdatedAt = $manageTimestamps && !\in_array('updated_at', $columns, true); + if ($addCreatedAt) { $columns[] = 'created_at'; } + if ($addUpdatedAt) { + $columns[] = 'updated_at'; + } $sql = 'INSERT INTO ' . $this->table; $sql .= ' (' . implode(', ', $columns) . ')'; @@ -1291,8 +1296,14 @@ public function upsert(array $values, ?array $update = null) $insertAbleValues = []; foreach ($values as $row) { ksort($row); - if ($createdAt) { - $row['created_at'] = $this->currentTimestamp(); + if ($addCreatedAt || $addUpdatedAt) { + $now = $this->currentTimestamp(); + if ($addCreatedAt) { + $row['created_at'] = $now; + } + if ($addUpdatedAt) { + $row['updated_at'] = $now; + } } $rowValues = array_values($row); @@ -1316,15 +1327,14 @@ function ($value) { $sql .= empty($insertAbleValues) ? ' default values' : ' ' . implode(',', $insertAbleValues); $sql .= ' ON DUPLICATE KEY UPDATE '; - if (\in_array('created_at', $update, true)) { - $update = array_diff($update, ['created_at']); - $update[] = 'updated_at'; + if ($manageTimestamps) { + // Never overwrite the original creation time on update; always bump updated_at. + $update = array_diff($update, ['created_at']); + if (!\in_array('updated_at', $update, true)) { + $update[] = 'updated_at'; + } } $update = array_map(function ($column) { - if ($column === 'updated_at') { - return $column . ' = VALUES(created_at)'; - } - return $column . ' = VALUES(' . $column . ')'; }, $update); $sql .= implode(', ', $update); diff --git a/tests/AttributeCastTest.php b/tests/AttributeCastTest.php new file mode 100644 index 0000000..fdfa01d --- /dev/null +++ b/tests/AttributeCastTest.php @@ -0,0 +1,44 @@ + '1']); + + $this->assertSame(true, $model->flag); + } + + /** + * withCast() on the builder must stay chainable (return the QueryBuilder). + */ + public function testWithCastOnBuilderIsChainable(): void + { + $this->assertInstanceOf(QueryBuilder::class, User::query()->withCast(['flag' => 'bool'])); + } +} diff --git a/tests/CollectionTest.php b/tests/CollectionTest.php new file mode 100644 index 0000000..4228e2a --- /dev/null +++ b/tests/CollectionTest.php @@ -0,0 +1,35 @@ + 1])]); + + $this->assertSame(['L'], $collection->pluck('label')->all()); + } +} diff --git a/tests/Fixtures/TimestampedRow.php b/tests/Fixtures/TimestampedRow.php new file mode 100644 index 0000000..f10d535 --- /dev/null +++ b/tests/Fixtures/TimestampedRow.php @@ -0,0 +1,18 @@ +upsert(['first_name' => 'Ada', 'email' => 'a@x.com']); - - $sql = $GLOBALS['wpdb']->last_query; - - $this->assertStringContainsString('(email, first_name)', $sql, 'columns must be sorted to match the ksorted row values'); - $this->assertStringContainsString("('a@x.com', 'Ada')", $sql); - } - - /** upsert: an explicit `created_at` in the update list must be rewritten to `updated_at`. */ - public function testUpsertSwapsCreatedAtForUpdatedAtOnDuplicate(): void - { - User::query()->upsert( - ['email' => 'a@x.com', 'created_at' => '2020-01-01 00:00:00'], - ['email', 'created_at'] - ); - - $sql = $GLOBALS['wpdb']->last_query; - - $this->assertStringContainsString('updated_at = VALUES(created_at)', $sql); - $this->assertStringNotContainsString('created_at = VALUES(created_at)', $sql); - } - - /** The documented `boolean` cast must convert the value, like `bool`. */ - public function testBooleanCastConvertsValue(): void - { - $model = new CastModel(['flag' => '1']); - - $this->assertSame(true, $model->flag); - } - - /** Collection::pluck must resolve dynamic (accessor) attributes on models. */ - public function testPluckResolvesAccessorAttribute(): void - { - $collection = new Collection([new AccessorModel(['id' => 1])]); - - $this->assertSame(['L'], $collection->pluck('label')->all()); - } - - /** withCast() on the builder must stay chainable (return the QueryBuilder). */ - public function testWithCastOnBuilderIsChainable(): void - { - $this->assertInstanceOf(QueryBuilder::class, User::query()->withCast(['flag' => 'bool'])); - } -} diff --git a/tests/UpsertTest.php b/tests/UpsertTest.php new file mode 100644 index 0000000..e90140f --- /dev/null +++ b/tests/UpsertTest.php @@ -0,0 +1,75 @@ +upsert(['first_name' => 'Ada', 'email' => 'a@x.com']); + + $sql = $GLOBALS['wpdb']->last_query; + + $this->assertStringContainsString('(email, first_name)', $sql); + $this->assertStringContainsString("('a@x.com', 'Ada')", $sql); + } + + /** + * With timestamps enabled, upsert inserts both created_at and updated_at, and + * on duplicate bumps updated_at (VALUES(updated_at)) while preserving created_at + * (created_at is excluded from the ON DUPLICATE KEY UPDATE set). + */ + public function testUpsertManagesTimestampsOnDuplicate(): void + { + TimestampedRow::query()->upsert(['email' => 'a@x.com', 'name' => 'Ada']); + + $sql = $GLOBALS['wpdb']->last_query; + + // both timestamp columns are inserted + $this->assertStringContainsString('created_at', $sql); + $this->assertStringContainsString('updated_at', $sql); + // updated_at is bumped from its own inserted value, not created_at + $this->assertStringContainsString('updated_at = VALUES(updated_at)', $sql); + $this->assertStringNotContainsString('VALUES(created_at)', $sql); + // created_at is preserved on update (never in the update set) + $this->assertStringNotContainsString('created_at = VALUES(created_at)', $sql); + } + + /** + * With timestamps disabled, upsert applies no timestamp magic — columns map verbatim. + */ + public function testUpsertWithoutTimestampsHasNoMagic(): void + { + User::query()->upsert( + ['email' => 'a@x.com', 'created_at' => '2020-01-01 00:00:00'], + ['email', 'created_at'] + ); + + $sql = $GLOBALS['wpdb']->last_query; + + $this->assertStringContainsString('created_at = VALUES(created_at)', $sql); + $this->assertStringNotContainsString('updated_at', $sql); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index c270bc7..c23a194 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -132,3 +132,4 @@ public function has_cap($cap) require __DIR__ . '/Fixtures/AccessorModel.php'; require __DIR__ . '/Fixtures/CreatingUser.php'; require __DIR__ . '/Fixtures/ScopedSoftPost.php'; +require __DIR__ . '/Fixtures/TimestampedRow.php'; From 3cac80ce420b2e4db8baa322a18d71424e8dd04e Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Tue, 30 Jun 2026 14:53:28 +0600 Subject: [PATCH 071/102] feat: add pivot-table many-to-many to belongsToMany (read-side) belongsToMany was a stub: it set a distinct relateAs tag but resolved identically to hasMany (no junction table). Extend it to a Laravel-style signature so it loads related rows through a pivot table. belongsToMany($model, $pivotTable = null, $foreignPivotKey = null, $relatedPivotKey = null, $parentKey = null, $relatedKey = null) The pivot branch JOINs the pivot table and carries the parent-link column back as an aliased select column, bucketing related rows on it; eager with() and lazy access both supported. withPivot() selects extra pivot columns, exposed flat as pivot_ attributes. Aggregates/whereHas over a pivot relation and withPivot() on a non-pivot relation fail loud. Zero-BC: each non-pivot path (hasMany, belongsTo/hasOne, legacy belongsToMany($m) with no pivot table) is byte-identical -- the pivot logic sits behind early relateAs guards above untouched bodies. PHP 7.4 compatible. Read-side only; attach/detach/sync deferred. Assisted-By: AI --- docs/breaking-changes.md | 34 +++++ docs/usage.md | 107 ++++++++++++-- src/Concerns/QueriesRelationships.php | 21 +++ src/Concerns/Relations.php | 148 ++++++++++++++++++- src/Model.php | 20 ++- tests/BelongsToManyPivotTest.php | 200 ++++++++++++++++++++++++++ tests/Fixtures/Member.php | 34 +++++ tests/Fixtures/Role.php | 14 ++ tests/bootstrap.php | 2 + 9 files changed, 563 insertions(+), 17 deletions(-) create mode 100644 tests/BelongsToManyPivotTest.php create mode 100644 tests/Fixtures/Member.php create mode 100644 tests/Fixtures/Role.php diff --git a/docs/breaking-changes.md b/docs/breaking-changes.md index c180c7e..481b6f9 100644 --- a/docs/breaking-changes.md +++ b/docs/breaking-changes.md @@ -309,6 +309,12 @@ Not signature breaks, but observable runtime differences. prior behavior that left `updated_at` untouched and mapped `updated_at = VALUES(created_at)`. The generated SQL changes for upsert on timestamped models. +- **`belongsToMany()` positional args 2 and 3 changed meaning** — from + `(foreignKey, localKey)` to `(pivotTable, foreignPivotKey)` (see §4.6). + `belongsToMany($model)` with no extra args is **byte-identical** to before + (legacy null-pivot path). Any call passing positional arg 2+ now takes the + pivot path, treating arg 2 as the pivot table name. Zero known callers across + consumers; flagged for completeness. --- @@ -410,6 +416,34 @@ User::query()->with('posts')->where('active', 1)->get(); - **QueryBuilder:** `static $TIME_ZONE` to set the timezone statically; `$select` / `$selectRaw` are now `public` (were `protected`). +### 4.6 Real pivot-table many-to-many on `belongsToMany` + +`belongsToMany` now resolves a true many-to-many relation through a pivot +(junction) table for **reads** — eager `with()` and lazy `$model->relation`: + +```php +public function roles() +{ + return $this->belongsToMany(Role::class, 'role_user', 'member_id', 'role_id'); +} + +Member::with('roles')->get(); // eager +$member->roles; // lazy +``` + +Full signature +`belongsToMany($model, $pivotTable = null, $foreignPivotKey = null, $relatedPivotKey = null, $parentKey = null, $relatedKey = null)`. +Omitted keys derive from the package FK convention (`members_id`, `roles_id`). +`withPivot([...])` selects extra pivot columns, exposed flat on each related +model as `pivot_` attributes (the parent link is always exposed as +`pivot_`). When `$pivotTable` is `null` the method keeps its +**legacy** behaviour (resolves like `hasMany`), so existing `belongsToMany($model)` +calls are unaffected. + +Out of scope (read-only): `attach`/`detach`/`sync`, and +`withCount`/`whereHas`/aggregates over a pivot relation (these throw +`RuntimeException`). See the usage doc's Limitations for the full list. + --- ## 5. Deprecations diff --git a/docs/usage.md b/docs/usage.md index 3deb44d..d02b6e7 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -472,13 +472,82 @@ class Deal extends Model > model. There is no separate reverse-direction implementation; the direction is > determined entirely by the keys you provide and which model calls the method. -Available: `hasOne()`, `hasMany()`, `belongsTo()`, `belongsToMany()` — each -takes `($model, $foreignKey = null, $localKey = null)`. +Available: `hasOne()`, `hasMany()`, `belongsTo()` — each takes +`($model, $foreignKey = null, $localKey = null)`. -> **`belongsToMany` is non-functional for pivot tables.** The method is -> declared but contains no pivot-table join logic. Calling it will not produce a -> many-to-many query through an intermediate table. See -> [Limitations](#limitations--known-issues). +### Many-to-many (`belongsToMany`) + +`belongsToMany` resolves a many-to-many relation through a pivot (junction) +table. Signature: + +```php +belongsToMany( + $model, + $pivotTable = null, // unprefixed pivot/junction table name + $foreignPivotKey = null, // parent's key column ON the pivot table + $relatedPivotKey = null, // related's key column ON the pivot table + $parentKey = null, // local key column on the parent table + $relatedKey = null // key column on the related table +) +``` + +When `$pivotTable` is `null` the method keeps its **legacy** behaviour (resolves +exactly like `hasMany` — the related table must carry the parent FK). Pass the +pivot table name (unprefixed; the package prefixes it like `join()` does) to get +real pivot behaviour. + +Omitted keys derive from the package's own foreign-key convention +(`{tableWithoutPrefix}_{primaryKey}`, e.g. `members_id` — note: plural, unlike +Laravel's singular default): + +| Argument | Default | Member (`members`) ↔ Role (`roles`) | +|---|---|---| +| `$foreignPivotKey` | parent `getForeignKey()` | `members_id` | +| `$relatedPivotKey` | related `getForeignKey()` | `roles_id` | +| `$parentKey` | parent `getPrimaryKey()` | `id` | +| `$relatedKey` | related `getPrimaryKey()` | `id` | + +```php +class Member extends Model +{ + protected $table = 'members'; + + public function roles() + { + // pivot table role_user(member_id, role_id) + return $this->belongsToMany(Role::class, 'role_user', 'member_id', 'role_id'); + } + + // Carry extra pivot columns; they surface flat as `pivot_` attributes + public function rolesWithAssignment() + { + return $this->belongsToMany(Role::class, 'role_user', 'member_id', 'role_id') + ->withPivot(['assigned_at']); + } +} +``` + +The link column rides along on every related model as the reserved attribute +`pivot_` (e.g. `pivot_member_id`), and each `withPivot()` column +as `pivot_` (e.g. `pivot_assigned_at`). These flat `pivot_*` attributes +appear in `toArray()`. + +```php +// Eager +foreach (Member::with('roles')->get() as $member) { + foreach ($member->roles as $role) { + echo $role->pivot_member_id; // the parent link + } +} + +// Lazy +$member = Member::query()->findOne(['id' => 1]); +foreach ($member->roles as $role) { /* ... */ } +``` + +Read-only only: `attach`/`detach`/`sync` and `withCount`/`whereHas`/aggregates +over a pivot relation are **not** supported (the latter throw). See +[Limitations](#limitations--known-issues). ### Eager loading @@ -663,10 +732,28 @@ method relocation. ## Limitations & known issues -- **`belongsToMany` is declared but non-functional.** The method exists but - contains no pivot-table join logic. Calling it does not produce a - many-to-many query through an intermediate table. Workaround: model the pivot - as an explicit intermediate model with `hasMany` on each side. +- **`belongsToMany` pivot relations are read-only and single-key.** Real + pivot-table many-to-many is supported for reads (eager `with()` + lazy + `$model->relation`), but with these gaps: + - No `withCount`/`whereHas`/aggregates on a pivot relation — they **throw** + `RuntimeException` (the pivot metadata has no single `foreignKey`/`localKey`). + - No `attach`/`detach`/`sync` (write side is out of scope). + - Single-column pivot/parent/related keys only — no composite keys. + - A non-empty model `$prefix` combined with a pivot table is unsupported (the + inherited `join()` prefixing quirk mis-prefixes the pivot table). The + default empty-`$prefix` case is correct. + - Duplicate pivot rows yield duplicate related models (no `DISTINCT`). + - Eager constraint closures may add `where`/`orderBy`/`limit` but **cannot** + narrow the selected columns — the pivot path always selects `related.*` so + the aliased pivot column can ride along. + - Pivot values surface as flat **reserved** `pivot_*` attributes (including the + link key `pivot_`) and appear in `toArray()`. A related + column literally named `pivot_*` would be overwritten. These attributes are + excluded from dirty-tracking on UPDATE, so re-saving a hydrated related model + is safe; a forced re-INSERT would attempt to write the non-existent columns. + - Null parent key: the eager path buckets a null parent key under null + (relation resolves to `null`); the lazy path renders `… IS NULL` and returns + pivot rows whose link column is NULL — a minor divergence. - **`belongsTo` and `hasOne` are the same alias; key naming is reversed from Laravel.** Both set the same `oneToOne` relation. The `$foreignKey` argument diff --git a/src/Concerns/QueriesRelationships.php b/src/Concerns/QueriesRelationships.php index 5e6366a..4b5ed88 100644 --- a/src/Concerns/QueriesRelationships.php +++ b/src/Concerns/QueriesRelationships.php @@ -7,8 +7,10 @@ namespace BitApps\WPDatabase\Concerns; +use BitApps\WPDatabase\Model; use BitApps\WPDatabase\QueryBuilder; use Closure; +use RuntimeException; if (!\defined('ABSPATH')) { exit; @@ -37,6 +39,21 @@ public function with($relation, $callback = null) return $this; } + /** + * Selects extra pivot-table columns for a pivot belongsToMany relation. + * They surface flat on each related model as `pivot_` attributes. + * + * @param array|string $columns + * + * @return $this + */ + public function withPivot($columns) + { + $this->_model->addPivotColumns(\is_array($columns) ? $columns : \func_get_args()); + + return $this; + } + /** * Adds a relation count sub query to this query. * @@ -284,6 +301,10 @@ private function resolveRelations($relation, $callback) */ private function correlate(QueryBuilder $relationalQuery) { + if ($relationalQuery->getModel()->getRelateAs() === Model::RELATE_AS_PIVOT) { + throw new RuntimeException('Relation aggregates and whereHas are not supported for pivot belongsToMany relations.'); + } + $relationKey = $relationalQuery->getModel()->getActiveRelationKey(); $relationalQuery->whereRaw( diff --git a/src/Concerns/Relations.php b/src/Concerns/Relations.php index a41f1d0..e5d11ed 100644 --- a/src/Concerns/Relations.php +++ b/src/Concerns/Relations.php @@ -9,6 +9,7 @@ use BitApps\WPDatabase\Model; use BitApps\WPDatabase\QueryBuilder; use Closure; +use RuntimeException; if (!\defined('ABSPATH')) { exit; @@ -81,13 +82,28 @@ public function newHasMany($model, $foreignKey = null, $localKey = null) return $model->newQuery(); } - public function belongsToMany($model, $foreignKey = null, $localKey = null) - { - $relationKeys = $this->getRelationKeys($foreignKey, $localKey); - $foreignKey = $relationKeys[0]; - $localKey = $relationKeys[1]; + public function belongsToMany( + $model, + $pivotTable = null, + $foreignPivotKey = null, + $relatedPivotKey = null, + $parentKey = null, + $relatedKey = null + ) { + if ($pivotTable === null) { + $relationKeys = $this->getRelationKeys($foreignPivotKey, $relatedPivotKey); + + return $this->newBelongsToMany($model, $relationKeys[0], $relationKeys[1]); + } - return $this->newBelongsToMany($model, $foreignKey, $localKey); + return $this->newBelongsToManyPivot( + $model, + $pivotTable, + $foreignPivotKey, + $relatedPivotKey, + $parentKey, + $relatedKey + ); } public function newBelongsToMany($model, $foreignKey = null, $localKey = null) @@ -102,6 +118,46 @@ public function newBelongsToMany($model, $foreignKey = null, $localKey = null) return $model->newQuery(); } + public function newBelongsToManyPivot( + $model, + $pivotTable, + $foreignPivotKey = null, + $relatedPivotKey = null, + $parentKey = null, + $relatedKey = null + ) { + $related = new $model(); + + $foreignPivotKey = $foreignPivotKey ?: $this->getForeignKey(); + $relatedPivotKey = $relatedPivotKey ?: $related->getForeignKey(); + $parentKey = $parentKey ?: $this->getPrimaryKey(); + $relatedKey = $relatedKey ?: $related->getPrimaryKey(); + + $related->setRelateAs(Model::RELATE_AS_PIVOT); + $related->_relationKeys[Model::RELATE_AS_PIVOT] = [ + 'pivotTable' => $pivotTable, + 'foreignPivotKey' => $foreignPivotKey, + 'relatedPivotKey' => $relatedPivotKey, + 'parentKey' => $parentKey, + 'relatedKey' => $relatedKey, + 'pivotColumns' => [], + ]; + + return $related->newQuery(); + } + + public function addPivotColumns(array $columns) + { + if (!isset($this->_relationKeys[Model::RELATE_AS_PIVOT])) { + throw new RuntimeException('withPivot() is only valid on a pivot belongsToMany relation.'); + } + + $this->_relationKeys[Model::RELATE_AS_PIVOT]['pivotColumns'] = array_merge( + $this->_relationKeys[Model::RELATE_AS_PIVOT]['pivotColumns'], + $columns + ); + } + /** * Returns list of query of relations * @@ -193,6 +249,12 @@ private function retrieveRelateData(QueryBuilder $query) if (\count($relations) > 0) { foreach ($relations as $relationName => $relationQuery) { + if ($relationQuery->getModel()->getRelateAs() === Model::RELATE_AS_PIVOT) { + $this->retrievePivotRelateData($relationName, $relationQuery, $query); + + continue; + } + $parentQuery = clone $query; $relationKey = $relationQuery->getModel()->getActiveRelationKey(); @@ -215,11 +277,73 @@ private function retrieveRelateData(QueryBuilder $query) } } + private function retrievePivotRelateData($relationName, QueryBuilder $relationQuery, QueryBuilder $query) + { + $bucketAlias = $this->applyPivotSelectAndJoin($relationQuery); + + $pivot = $relationQuery->getModel()->getActiveRelationKey(); + $pivotRef = $relationQuery->getModel()->getPrefix() . $pivot['pivotTable']; + $parentQuery = clone $query; + + $relationQuery->whereRaw( + $pivotRef . '.' . $pivot['foreignPivotKey'] + . ' IN ( SELECT * FROM (' + . $parentQuery->select($pivot['parentKey'])->prepare() + . ') AS subquery )' + ); + + $relatedModels = $relationQuery->get(); + + if ($relatedModels) { + foreach ($relatedModels as $relatedModel) { + $this->_relatedData[$relationName][$relatedModel->getAttribute($bucketAlias)][] = $relatedModel; + } + } + } + + /** + * Selects related.* plus the aliased pivot link column, joins the pivot + * table on the related key, and appends any withPivot() columns. Returns the + * bucket alias used to group related rows by their parent link. + * + * @return string + */ + private function applyPivotSelectAndJoin(QueryBuilder $relationQuery) + { + $model = $relationQuery->getModel(); + $pivot = $model->getActiveRelationKey(); + $pivotRef = $model->getPrefix() . $pivot['pivotTable']; + $alias = Model::PIVOT_ATTRIBUTE_PREFIX . $pivot['foreignPivotKey']; + + $relationQuery->select(['*']); + $relationQuery->join( + $pivot['pivotTable'], + $pivotRef . '.' . $pivot['relatedPivotKey'], + '=', + $relationQuery->getTable() . '.' . $pivot['relatedKey'] + ); + $relationQuery->selectRaw($pivotRef . '.' . $pivot['foreignPivotKey'] . ' as `' . $alias . '`'); + + foreach ($pivot['pivotColumns'] as $column) { + $relationQuery->selectRaw( + $pivotRef . '.' . $column . ' as `' . Model::PIVOT_ATTRIBUTE_PREFIX . $column . '`' + ); + } + + return $alias; + } + private function setRelatedData(Model $model) { $relations = $this->getRelations(); if (\count($relations) > 0) { foreach ($relations as $relationName => $relationQuery) { + if ($relationQuery->getModel()->getRelateAs() === Model::RELATE_AS_PIVOT) { + $this->setPivotRelatedData($model, $relationName, $relationQuery->getModel()); + + continue; + } + $relationKey = $relationQuery->getModel()->getActiveRelationKey(); $data = isset( @@ -236,4 +360,16 @@ private function setRelatedData(Model $model) } } } + + private function setPivotRelatedData(Model $model, $relationName, Model $relatedModel) + { + $pivot = $relatedModel->getActiveRelationKey(); + $key = $model->getAttribute($pivot['parentKey']); + $data = isset($this->_relatedData[$relationName][$key]) + ? $this->_relatedData[$relationName][$key] + : null; + + [$name, $alias] = $this->prepareRelationName($relationName); + $model->setAttribute(\is_null($alias) ? $name : $alias, $data); + } } diff --git a/src/Model.php b/src/Model.php index a6f4d57..7ab1cc8 100644 --- a/src/Model.php +++ b/src/Model.php @@ -73,6 +73,7 @@ * @method static Model|bool save() * @method static Model|bool upsert(array $values, ?array $update = null) * @method static QueryBuilder with(string|array $relation, ?Closure $callback = null) + * @method static QueryBuilder withPivot($columns) * @method static QueryBuilder withCount(string|array $relation) * @method static QueryBuilder withMin(string|array $relation) * @method static QueryBuilder withMax(string|array $relation) @@ -96,6 +97,10 @@ abstract class Model implements ArrayAccess, JsonSerializable { use Relations, HasEvents; + public const RELATE_AS_PIVOT = 'belongsToManyPivot'; + + public const PIVOT_ATTRIBUTE_PREFIX = 'pivot_'; + public $timestamps = true; protected $table; @@ -588,7 +593,20 @@ private function castToDate($value) private function processRelatedAttribute(QueryBuilder $attribute) { - $relation = $attribute->getModel()->getRelateAs(); + $relation = $attribute->getModel()->getRelateAs(); + + if ($relation === self::RELATE_AS_PIVOT) { + $this->applyPivotSelectAndJoin($attribute); + $pivot = $attribute->getModel()->getActiveRelationKey(); + $pivotRef = $attribute->getModel()->getPrefix() . $pivot['pivotTable']; + $attribute->where( + $pivotRef . '.' . $pivot['foreignPivotKey'], + $this->getAttribute($pivot['parentKey']) + ); + + return $attribute->get(); + } + $relationKey = $attribute->getModel()->getRelationalKeys()[$relation]; $attribute->where($relationKey['foreignKey'], $this->getAttribute($relationKey['localKey'])); if ($relation == 'oneToOne') { diff --git a/tests/BelongsToManyPivotTest.php b/tests/BelongsToManyPivotTest.php new file mode 100644 index 0000000..b762189 --- /dev/null +++ b/tests/BelongsToManyPivotTest.php @@ -0,0 +1,200 @@ + 100, 'pivot_member_id' => 1, 'pivot_assigned_at' => '2024-01-01'], + (object) ['id' => 101, 'pivot_member_id' => 1, 'pivot_assigned_at' => '2024-02-01'], + (object) ['id' => 102, 'pivot_member_id' => 2, 'pivot_assigned_at' => '2024-03-01'], + ]; + } + + return [(object) ['id' => 1], (object) ['id' => 2]]; + }; + } + + public function testEagerLoadGroupsRelatedRowsByParent(): void + { + $GLOBALS['wpdb']->resolver = $this->pivotResolver(); + + $members = Member::with('roles')->get(); + + $this->assertInstanceOf(Collection::class, $members); + $this->assertCount(2, $members); + $this->assertCount(2, $members[0]->roles, 'member 1 should have 2 roles'); + $this->assertCount(1, $members[1]->roles, 'member 2 should have 1 role'); + $this->assertInstanceOf(Role::class, $members[0]->roles[0]); + $this->assertSame(100, $members[0]->roles[0]->id); + $this->assertSame(101, $members[0]->roles[1]->id); + $this->assertSame(102, $members[1]->roles[0]->id); + } + + public function testEagerSqlShape(): void + { + $GLOBALS['wpdb']->resolver = $this->pivotResolver(); + + Member::with('roles')->get(); + $sql = $GLOBALS['wpdb']->queries[1]; + + $this->assertStringContainsString('SELECT `wp_roles`.*', $sql); + $this->assertStringContainsString('wp_role_user.member_id as `pivot_member_id`', $sql); + $this->assertStringContainsString('INNER JOIN wp_role_user', $sql); + $this->assertStringContainsString('wp_role_user.role_id = wp_roles.id', $sql); + // Inner fragment without a leading `WHERE ` boundary: the grammar emits `WHERE ` (double space). + $this->assertStringContainsString( + 'wp_role_user.member_id IN ( SELECT * FROM (SELECT `wp_members`.`id` FROM wp_members) AS subquery )', + $sql + ); + + // Exact pin (absorbs the double-space WHERE/ON grammar artifacts). + $expected = 'SELECT `wp_roles`.*, wp_role_user.member_id as `pivot_member_id`' + . ' FROM wp_roles INNER JOIN wp_role_user ON wp_role_user.role_id = wp_roles.id' + . ' WHERE wp_role_user.member_id IN ( SELECT * FROM (SELECT `wp_members`.`id` FROM wp_members) AS subquery )'; + $this->assertSame($expected, $sql); + } + + public function testLazyAccessEmitsSingleValuePredicate(): void + { + $GLOBALS['wpdb']->resolver = $this->pivotResolver(); + + $member = new Member(['id' => 1]); + $roles = $member->roles; + + $this->assertInstanceOf(Collection::class, $roles); + + $sql = $GLOBALS['wpdb']->last_query; + $this->assertStringNotContainsString('subquery', $sql, 'lazy access must not use the IN ( SELECT ) subquery form'); + + // Exact pin: single-value predicate, double-space WHERE/ON/`=` grammar artifacts. + $expected = 'SELECT `wp_roles`.*, wp_role_user.member_id as `pivot_member_id`' + . ' FROM wp_roles INNER JOIN wp_role_user ON wp_role_user.role_id = wp_roles.id' + . ' WHERE wp_role_user.member_id = 1'; + $this->assertSame($expected, $sql); + } + + public function testDefaultKeyDerivationUsesForeignKeyConvention(): void + { + $GLOBALS['wpdb']->resolver = function ($sql) { + if (strpos($sql, 'wp_roles') !== false) { + return [ + (object) ['id' => 100, 'pivot_members_id' => 1], + (object) ['id' => 101, 'pivot_members_id' => 1], + (object) ['id' => 102, 'pivot_members_id' => 2], + ]; + } + + return [(object) ['id' => 1], (object) ['id' => 2]]; + }; + + $members = Member::with('rolesDefaultKeys')->get(); + $sql = $GLOBALS['wpdb']->queries[1]; + + $this->assertStringContainsString('wp_role_user.members_id as `pivot_members_id`', $sql); + $this->assertStringContainsString('wp_role_user.roles_id = wp_roles.id', $sql); + $this->assertCount(2, $members[0]->rolesDefaultKeys); + $this->assertCount(1, $members[1]->rolesDefaultKeys); + } + + public function testWithPivotSelectsAndExposesExtraColumn(): void + { + $GLOBALS['wpdb']->resolver = $this->pivotResolver(); + + $members = Member::with('rolesWithPivot')->get(); + $sql = $GLOBALS['wpdb']->queries[1]; + + $this->assertStringContainsString('wp_role_user.assigned_at as `pivot_assigned_at`', $sql); + $this->assertSame('2024-01-01', $members[0]->rolesWithPivot[0]->pivot_assigned_at); + } + + public function testLegacyNullPivotPathIsUnchanged(): void + { + $query = (new Member())->legacyRoles(); + + $this->assertInstanceOf(QueryBuilder::class, $query); + $this->assertSame('belongsToMany', $query->getModel()->getRelateAs()); + $this->assertSame( + ['foreignKey' => 'members_id', 'localKey' => 'id'], + $query->getModel()->getActiveRelationKey() + ); + } + + public function testAggregatesOnPivotRelationThrow(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('pivot belongsToMany'); + + Member::withCount('roles')->get(); + } + + public function testBucketAliasLeaksAsReservedAttribute(): void + { + $GLOBALS['wpdb']->resolver = $this->pivotResolver(); + + $members = Member::with('roles')->get(); + + $this->assertSame(1, $members[0]->roles[0]->pivot_member_id); + $this->assertSame(2, $members[1]->roles[0]->pivot_member_id); + } + + public function testMultiplePivotRelationsResolveIndependently(): void + { + $GLOBALS['wpdb']->resolver = function ($sql) { + if (strpos($sql, 'assigned_at') !== false) { + return [ + (object) ['id' => 200, 'pivot_member_id' => 1, 'pivot_assigned_at' => '2024-01-01'], + (object) ['id' => 201, 'pivot_member_id' => 2, 'pivot_assigned_at' => '2024-02-01'], + ]; + } + + if (strpos($sql, 'wp_roles') !== false) { + return [ + (object) ['id' => 100, 'pivot_member_id' => 1], + (object) ['id' => 102, 'pivot_member_id' => 2], + ]; + } + + return [(object) ['id' => 1], (object) ['id' => 2]]; + }; + + $members = Member::with(['roles', 'rolesWithPivot'])->get(); + + $this->assertCount(1, $members[0]->roles); + $this->assertCount(1, $members[0]->rolesWithPivot); + $this->assertSame(100, $members[0]->roles[0]->id); + $this->assertSame(200, $members[0]->rolesWithPivot[0]->id); + $this->assertSame('2024-01-01', $members[0]->rolesWithPivot[0]->pivot_assigned_at); + $this->assertNull($members[0]->roles[0]->pivot_assigned_at, 'roles must not pick up the withPivot column'); + } + + public function testPivotConstantsAreExposedOnModel(): void + { + $this->assertSame('belongsToManyPivot', Model::RELATE_AS_PIVOT); + $this->assertSame('pivot_', Model::PIVOT_ATTRIBUTE_PREFIX); + } +} diff --git a/tests/Fixtures/Member.php b/tests/Fixtures/Member.php new file mode 100644 index 0000000..dc33e82 --- /dev/null +++ b/tests/Fixtures/Member.php @@ -0,0 +1,34 @@ +belongsToMany(Role::class, 'role_user', 'member_id', 'role_id'); + } + + public function rolesDefaultKeys() + { + return $this->belongsToMany(Role::class, 'role_user'); + } + + public function rolesWithPivot() + { + return $this->belongsToMany(Role::class, 'role_user', 'member_id', 'role_id')->withPivot(['assigned_at']); + } + + public function legacyRoles() + { + return $this->belongsToMany(Role::class); + } +} diff --git a/tests/Fixtures/Role.php b/tests/Fixtures/Role.php new file mode 100644 index 0000000..88ecdfb --- /dev/null +++ b/tests/Fixtures/Role.php @@ -0,0 +1,14 @@ + Date: Tue, 30 Jun 2026 15:28:37 +0600 Subject: [PATCH 072/102] fix: join and pivot tables keep wp_ prefix for custom-$prefix models 3e5db3a dropped the leading Connection::wpPrefix() from join() to stop default-prefix models double-prefixing. That broke custom-$prefix models (e.g. bit-crm): Model::getPrefix() returns the raw $prefix without wp_, so join/pivot tables lost the wp_ prefix and pointed at the wrong table. Add Model::getTablePrefix() -- the full prefix (wp_ plus plugin prefix) that mirrors how the constructor builds $this->table -- and route the constructor, join(), and the pivot select/join helper through it. getPrefix() is unchanged (public API). Default-prefix models stay byte-identical (getTablePrefix() === getPrefix()); custom-prefix joins and pivots regain wp_. Also simplify the pivot helper: applyPivotSelectAndJoin() returns [pivot, pivotRef, alias] so callers stop recomputing them. Assisted-By: AI --- src/Concerns/Relations.php | 18 +++++++-------- src/Model.php | 26 +++++++++++++-------- src/QueryBuilder.php | 2 +- tests/Fixtures/PrefixedModel.php | 18 +++++++++++++++ tests/TablePrefixTest.php | 39 ++++++++++++++++++++++++++++++++ tests/bootstrap.php | 1 + 6 files changed, 83 insertions(+), 21 deletions(-) create mode 100644 tests/Fixtures/PrefixedModel.php create mode 100644 tests/TablePrefixTest.php diff --git a/src/Concerns/Relations.php b/src/Concerns/Relations.php index e5d11ed..0ba2271 100644 --- a/src/Concerns/Relations.php +++ b/src/Concerns/Relations.php @@ -279,11 +279,8 @@ private function retrieveRelateData(QueryBuilder $query) private function retrievePivotRelateData($relationName, QueryBuilder $relationQuery, QueryBuilder $query) { - $bucketAlias = $this->applyPivotSelectAndJoin($relationQuery); - - $pivot = $relationQuery->getModel()->getActiveRelationKey(); - $pivotRef = $relationQuery->getModel()->getPrefix() . $pivot['pivotTable']; - $parentQuery = clone $query; + [$pivot, $pivotRef, $bucketAlias] = $this->applyPivotSelectAndJoin($relationQuery); + $parentQuery = clone $query; $relationQuery->whereRaw( $pivotRef . '.' . $pivot['foreignPivotKey'] @@ -303,16 +300,17 @@ private function retrievePivotRelateData($relationName, QueryBuilder $relationQu /** * Selects related.* plus the aliased pivot link column, joins the pivot - * table on the related key, and appends any withPivot() columns. Returns the - * bucket alias used to group related rows by their parent link. + * table on the related key, and appends any withPivot() columns. Returns + * [pivot metadata, prefixed pivot-table reference, bucket alias] so callers + * build their predicate without recomputing them. * - * @return string + * @return array */ private function applyPivotSelectAndJoin(QueryBuilder $relationQuery) { $model = $relationQuery->getModel(); $pivot = $model->getActiveRelationKey(); - $pivotRef = $model->getPrefix() . $pivot['pivotTable']; + $pivotRef = $model->getTablePrefix() . $pivot['pivotTable']; $alias = Model::PIVOT_ATTRIBUTE_PREFIX . $pivot['foreignPivotKey']; $relationQuery->select(['*']); @@ -330,7 +328,7 @@ private function applyPivotSelectAndJoin(QueryBuilder $relationQuery) ); } - return $alias; + return [$pivot, $pivotRef, $alias]; } private function setRelatedData(Model $model) diff --git a/src/Model.php b/src/Model.php index 7ab1cc8..b0c05f3 100644 --- a/src/Model.php +++ b/src/Model.php @@ -158,13 +158,7 @@ public function __construct($attributes = []) $this->_tableWithoutPrefix = $this->table; } - $dbPrefix = Connection::wpPrefix(); - - if ($this->prefix === '') { - $dbPrefix = Connection::getPrefix(); - } - - $this->table = $dbPrefix . $this->prefix . $this->_tableWithoutPrefix; + $this->table = $this->getTablePrefix() . $this->_tableWithoutPrefix; if (!isset($this->primaryKey)) { $this->primaryKey = 'id'; @@ -302,11 +296,25 @@ public function getTable() return $this->table; } + /** Query/schema default prefix; NOT the full table prefix — use getTablePrefix() for join/pivot table names (it keeps wp_ for custom $prefix). */ public function getPrefix() { return $this->prefix === '' ? Connection::getPrefix() : $this->prefix; } + /** + * Full prefix prepended to this model's table: wp_ plus the plugin prefix. + * Mirrors the table built in the constructor so joins and pivot tables + * resolve to the same physical table the model itself targets. Unlike + * getPrefix(), this never drops wp_ for custom-$prefix models. + * + * @return string + */ + public function getTablePrefix() + { + return $this->prefix === '' ? Connection::getPrefix() : Connection::wpPrefix() . $this->prefix; + } + public function getTableWithoutPrefix() { return $this->_tableWithoutPrefix; @@ -596,9 +604,7 @@ private function processRelatedAttribute(QueryBuilder $attribute) $relation = $attribute->getModel()->getRelateAs(); if ($relation === self::RELATE_AS_PIVOT) { - $this->applyPivotSelectAndJoin($attribute); - $pivot = $attribute->getModel()->getActiveRelationKey(); - $pivotRef = $attribute->getModel()->getPrefix() . $pivot['pivotTable']; + [$pivot, $pivotRef] = $this->applyPivotSelectAndJoin($attribute); $attribute->where( $pivotRef . '.' . $pivot['foreignPivotKey'], $this->getAttribute($pivot['parentKey']) diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index cafb12e..e319ca3 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -770,7 +770,7 @@ public function join($table, $firstColumn, $operator = null, $secondColumn = nul $parts = preg_split('/ as /i', $table); $rawTable = $parts[0]; $alias = isset($parts[1]) ? $parts[1] : null; - $prefixedTable = $this->_model->getPrefix() . $rawTable; + $prefixedTable = $this->_model->getTablePrefix() . $rawTable; $reference = $alias !== null ? $alias : $prefixedTable; $tableSql = $alias !== null ? $prefixedTable . ' as ' . $alias : $prefixedTable; diff --git a/tests/Fixtures/PrefixedModel.php b/tests/Fixtures/PrefixedModel.php new file mode 100644 index 0000000..056211f --- /dev/null +++ b/tests/Fixtures/PrefixedModel.php @@ -0,0 +1,18 @@ +. + */ +class PrefixedModel extends Model +{ + public $timestamps = false; + + protected $table = 'widgets'; + + protected $prefix = 'crm_'; +} diff --git a/tests/TablePrefixTest.php b/tests/TablePrefixTest.php new file mode 100644 index 0000000..40dfb8f --- /dev/null +++ b/tests/TablePrefixTest.php @@ -0,0 +1,39 @@ +..., + * not just ... — regression guard for the join double-prefix fix. + */ +final class TablePrefixTest extends TestCase +{ + protected function setUp(): void + { + $GLOBALS['wpdb'] = new FakeWpdb(); + } + + protected function tearDown(): void + { + $GLOBALS['wpdb'] = new FakeWpdb(); + } + + public function testCustomPrefixModelTableCarriesWpPrefix(): void + { + $this->assertSame('wp_crm_widgets', (new PrefixedModel())->getTable()); + } + + public function testJoinOnCustomPrefixModelKeepsWpPrefix(): void + { + $sql = (new PrefixedModel()) + ->join('gadgets', 'gadgets.widget_id', '=', 'widgets.id') + ->toSql(); + + $this->assertStringContainsString('JOIN wp_crm_gadgets', $sql); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 962d69e..8fb7b06 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -135,3 +135,4 @@ public function has_cap($cap) require __DIR__ . '/Fixtures/TimestampedRow.php'; require __DIR__ . '/Fixtures/Role.php'; require __DIR__ . '/Fixtures/Member.php'; +require __DIR__ . '/Fixtures/PrefixedModel.php'; From 7aa24f2ccb17cfcb36dd433f627729c0ab1ee4c5 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Tue, 30 Jun 2026 15:51:28 +0600 Subject: [PATCH 073/102] fix: bulk insert and upsert JSON-encode array/object values save()/update() encode array and object attribute values via wp_json_encode(), but bulkInsert() (insert([[...],[...]])) and upsert() bound them raw -- an array became the literal "Array" and an object threw "could not be converted to string". Mirror the save() encoding in both bulk value builders so callers don't have to wp_json_encode() manually. Scalars are unchanged; repairs already-broken output (zero-BC). Assisted-By: AI --- src/QueryBuilder.php | 8 ++++++++ tests/BulkInsertReturnTest.php | 21 +++++++++++++++++++++ tests/UpsertTest.php | 13 +++++++++++++ 3 files changed, 42 insertions(+) diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index e319ca3..6791e12 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -1316,6 +1316,10 @@ function ($value) { return 'NULL'; } + if (\is_array($value) || \is_object($value)) { + $value = wp_json_encode($value); + } + $this->bindings[] = $value; return $this->getValueType($value); @@ -1569,6 +1573,10 @@ function ($value) { return 'NULL'; } + if (\is_array($value) || \is_object($value)) { + $value = wp_json_encode($value); + } + $this->bindings[] = $value; return $this->getValueType($value); diff --git a/tests/BulkInsertReturnTest.php b/tests/BulkInsertReturnTest.php index b900f48..359ff6c 100644 --- a/tests/BulkInsertReturnTest.php +++ b/tests/BulkInsertReturnTest.php @@ -50,4 +50,25 @@ public function testBulkInsertHappyPathReturnsCollection(): void $this->assertInstanceOf(Collection::class, $result); } + + /** + * Array/object values must be JSON-encoded in bulk insert, matching save()/ + * update() — so callers don't have to wp_json_encode() them manually. + */ + public function testBulkInsertEncodesArrayAndObjectValuesAsJson(): void + { + // rows_affected = 0 keeps the INSERT as last_query (no post-insert re-query). + $GLOBALS['wpdb']->rows_affected = 0; + + User::query()->insert([ + ['name' => 'a', 'meta' => ['x' => 1]], + ['name' => 'b', 'meta' => (object) ['y' => 2]], + ]); + + $sql = $GLOBALS['wpdb']->last_query; + + $this->assertStringContainsString('{"x":1}', $sql); + $this->assertStringContainsString('{"y":2}', $sql); + $this->assertStringNotContainsString('Array', $sql); + } } diff --git a/tests/UpsertTest.php b/tests/UpsertTest.php index e90140f..c019a15 100644 --- a/tests/UpsertTest.php +++ b/tests/UpsertTest.php @@ -72,4 +72,17 @@ public function testUpsertWithoutTimestampsHasNoMagic(): void $this->assertStringContainsString('created_at = VALUES(created_at)', $sql); $this->assertStringNotContainsString('updated_at', $sql); } + + /** + * Array/object values are JSON-encoded, matching bulk insert and save(). + */ + public function testUpsertEncodesArrayValuesAsJson(): void + { + User::query()->upsert(['email' => 'a@x.com', 'meta' => ['x' => 1]]); + + $sql = $GLOBALS['wpdb']->last_query; + + $this->assertStringContainsString('{"x":1}', $sql); + $this->assertStringNotContainsString('Array', $sql); + } } From 8b21d244e89befa11323c4236d79aafbbc7dc7a6 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Tue, 30 Jun 2026 16:02:12 +0600 Subject: [PATCH 074/102] docs: document JSON-encoded insert/upsert values and getTablePrefix join fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Note that array/object values are JSON-encoded across save/update/insert/ bulk insert/upsert (usage + breaking-changes §3). - Note join() prepends the model's full table prefix; add getTablePrefix() to the additions list and a §3 behavioral note for the custom-$prefix fix. - Drop the now-stale pivot limitation claiming custom-$prefix is unsupported. Assisted-By: AI --- docs/breaking-changes.md | 13 ++++++++++++- docs/usage.md | 11 ++++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/docs/breaking-changes.md b/docs/breaking-changes.md index 481b6f9..752c86a 100644 --- a/docs/breaking-changes.md +++ b/docs/breaking-changes.md @@ -315,6 +315,16 @@ Not signature breaks, but observable runtime differences. (legacy null-pivot path). Any call passing positional arg 2+ now takes the pivot path, treating arg 2 as the pivot table name. Zero known callers across consumers; flagged for completeness. +- **Bulk `insert()` and `upsert()` now JSON-encode array/object values** via + `wp_json_encode`, matching `save()`/`update()`. Previously a multi-row + `insert([[...]])` or `upsert()` bound an array as the literal `"Array"` and an + object threw — both repaired. Scalar values are unchanged. +- **`join()` table prefix corrected for custom-`$prefix` models.** Join (and + pivot) tables now carry the model's **full** table prefix via the new + `Model::getTablePrefix()` (`wp_` plus the plugin prefix), matching the model's + own table. Default-`$prefix` models are unchanged (`getTablePrefix()` equals + `getPrefix()` there); custom-`$prefix` models that previously lost `wp_` on + joins now match their own table. --- @@ -409,7 +419,8 @@ User::query()->with('posts')->where('active', 1)->get(); `when()`, `toSql()`, `clone()`, `aggregate()`, `prepareColumnName()`, `withCast()` (chainable), and `__call()` forwarding to the bound model. - **Model:** `query()` (canonical static builder entry), `toArray()`, - `getPrefix()`, `withCast(array $casts)`, `bool`/`boolean` cast. + `getPrefix()`, `getTablePrefix()` (full table prefix — `wp_` + plugin prefix — + for join/pivot table names), `withCast(array $casts)`, `bool`/`boolean` cast. - **Connection:** `startTransaction()`, `commit()`, `rollback()`. - **Blueprint:** `unique($column = null)` — optional arg (backward compatible) for composite/explicit unique indexes. diff --git a/docs/usage.md b/docs/usage.md index d02b6e7..8779f7b 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -154,6 +154,10 @@ $created = Contact::insert([ `save()` inserts when the model is new and updates (dirty attributes only) when it already exists. On success it returns the model; on failure, `false`. +Array and object values are JSON-encoded (`wp_json_encode`) automatically across +`save()`, `update()`, single-row and bulk `insert()`, and `upsert()` — pass the +raw array/object, not a pre-encoded string. + --- ## Reading records @@ -268,6 +272,10 @@ Contact::query() // also: rightJoin(), fullJoin(), crossJoin(), on(), orOn() ``` +Pass **unprefixed** table names — `join()` prepends the model's full table prefix +(the same one the model's own table uses, including `wp_` for models with a custom +`$prefix`). Qualify the `ON` columns as `table.column`. + ### Limit / offset / pagination ```php @@ -739,9 +747,6 @@ method relocation. `RuntimeException` (the pivot metadata has no single `foreignKey`/`localKey`). - No `attach`/`detach`/`sync` (write side is out of scope). - Single-column pivot/parent/related keys only — no composite keys. - - A non-empty model `$prefix` combined with a pivot table is unsupported (the - inherited `join()` prefixing quirk mis-prefixes the pivot table). The - default empty-`$prefix` case is correct. - Duplicate pivot rows yield duplicate related models (no `DISTINCT`). - Eager constraint closures may add `where`/`orderBy`/`limit` but **cannot** narrow the selected columns — the pivot path always selects `related.*` so From a43e80ea2add60b440cc7d650d8fff13aafd4495 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Tue, 30 Jun 2026 16:37:24 +0600 Subject: [PATCH 075/102] fix: select() handles `column AS alias` without back-ticking the alias prepareColumnName() wrapped the whole "title AS t" string as a single identifier (`table`.`title AS t`), so select(['col AS alias']) produced "Unknown column 'table.col AS alias'" errors. Split on ` AS ` (case-insensitive): qualify the column, keep the alias as a separate back-ticked identifier -> `table`.`col` AS `alias`. Plain/dotted columns and `*` are unchanged; raw expressions and function calls still need selectRaw(). Assisted-By: AI --- docs/breaking-changes.md | 12 ++++++++---- docs/usage.md | 6 ++++-- src/QueryBuilder.php | 4 ++++ tests/QueryFeaturesTest.php | 12 ++++++++++++ 4 files changed, 28 insertions(+), 6 deletions(-) diff --git a/docs/breaking-changes.md b/docs/breaking-changes.md index 752c86a..d0f42f7 100644 --- a/docs/breaking-changes.md +++ b/docs/breaking-changes.md @@ -128,13 +128,17 @@ which wraps the name as `` `table`.`column` `` unless it already contains a `.`. ->select('COUNT(*) as total') // emitted: `COUNT(*) as total` ❌ invalid SQL ``` -**Why it breaks:** any raw SQL expression, function call, or alias passed to -`select()` is now quoted as a single identifier. +**Why it breaks:** a raw SQL expression or function call passed to `select()` is +quoted as a single identifier. -**Migration:** use `selectRaw()` for expressions; plain columns need no change: +A plain `column AS alias` **is** handled — `prepareColumnName()` qualifies the +column and keeps the alias separate, so `->select(['id', 'title AS t'])` emits +`` `table`.`id`, `table`.`title` AS `t` ``. Only expressions/function calls +(`COUNT(*)`, `SUM(amount) AS amt`, …) still need `selectRaw()`: ```php -->selectRaw('COUNT(*) as total') +->select(['title AS t']) // ✅ simple column alias +->selectRaw('COUNT(*) as total') // expressions / functions ->selectRaw('SUM(amount) as amt', $bindings) ``` diff --git a/docs/usage.md b/docs/usage.md index 8779f7b..2433bd9 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -198,13 +198,15 @@ inspect the SQL without executing. ```php Contact::select('id', 'email')->get(); Contact::select(['id', 'email'])->get(); +Contact::select(['id', 'title AS t'])->get(); // column alias — qualifies `title`, keeps AS `t` Contact::addSelect('phone')->get(); // add to existing select Contact::selectRaw('COUNT(*) as total')->get(); // raw expression (NOT select()) Contact::selectRaw('SUM(amount) as amt', [])->get(); ``` -> Pass raw SQL expressions to `selectRaw()`, not `select()` — `select()` -> back-tick-quotes its arguments as column identifiers. +> `select()` handles plain columns and `column AS alias`, back-tick-quoting them +> as identifiers. Pass raw SQL expressions or function calls (`COUNT(*)`, …) to +> `selectRaw()` instead. ### Where diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index 6791e12..0c6b45e 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -361,6 +361,10 @@ public function all($columns = ['*']) public function prepareColumnName(string $column) { + if (preg_match('/^(.+?)\s+as\s+(.+)$/i', $column, $matches)) { + return $this->prepareColumnName(trim($matches[1])) . ' AS `' . trim($matches[2], " `") . '`'; + } + if (strpos($column, '.') !== false) { return $column; } diff --git a/tests/QueryFeaturesTest.php b/tests/QueryFeaturesTest.php index d66d6a8..b0bb66a 100644 --- a/tests/QueryFeaturesTest.php +++ b/tests/QueryFeaturesTest.php @@ -24,6 +24,18 @@ protected function tearDown(): void $GLOBALS['wpdb'] = new FakeWpdb(); } + // --- Select -------------------------------------------------------------- + + public function testSelectColumnAliasQualifiesColumnNotWholeExpression(): void + { + $sql = (new User())->select(['id', 'name AS n'])->toSql(); + + // the column is qualified/back-ticked, the alias kept separate + $this->assertStringContainsString('`name` AS `n`', $sql); + // the whole "name AS n" must NOT be treated as one column name + $this->assertStringNotContainsString('`name AS n`', $sql); + } + // --- Joins --------------------------------------------------------------- public function testInnerJoinCompilesWithOnClause(): void From 08b8384f5a3ffb29685407537d2aeca8cef271f1 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Tue, 30 Jun 2026 18:14:34 +0600 Subject: [PATCH 076/102] fix: eager-load key subquery ignores parent selectRaw with() builds `foreignKey IN ( SELECT * FROM () AS subquery )` from a clone of the parent query narrowed to the local key. select() resets the select list but not selectRaw, so a parent selectRaw('... as x') leaked a second column into the subquery -> MySQL "Operand should contain 1 column(s)". Strip selectRaw on the key-subquery clone via a shared prepareKeySubquery() helper, used by both the hasMany/belongsTo and pivot eager paths. Byte-identical when the parent has no selectRaw. Assisted-By: AI --- src/Concerns/Relations.php | 23 +++++++++++++++++++---- tests/EagerLoadIntegrationTest.php | 18 ++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/src/Concerns/Relations.php b/src/Concerns/Relations.php index 0ba2271..4012c42 100644 --- a/src/Concerns/Relations.php +++ b/src/Concerns/Relations.php @@ -243,6 +243,23 @@ private function getRelationKeys($foreignKey, $localKey) return [$foreignKey, $localKey]; } + /** + * Compiles the parent query as a single-column key subquery for a relation's + * IN (...) constraint. Strips the parent's selectRaw — that subquery must + * return exactly the key column, not the caller's extra raw expressions. + * + * @param string $keyColumn + * + * @return string + */ + private function prepareKeySubquery(QueryBuilder $query, $keyColumn): string + { + $keyQuery = clone $query; + $keyQuery->selectRaw = ['columns' => [], 'bindings' => []]; + + return $keyQuery->select($keyColumn)->prepare(); + } + private function retrieveRelateData(QueryBuilder $query) { $relations = $this->getRelations(); @@ -255,13 +272,12 @@ private function retrieveRelateData(QueryBuilder $query) continue; } - $parentQuery = clone $query; $relationKey = $relationQuery->getModel()->getActiveRelationKey(); $relationQuery->whereRaw( $relationKey['foreignKey'] . ' IN ( SELECT * FROM (' - . $parentQuery->select($relationKey['localKey'])->prepare() + . $this->prepareKeySubquery($query, $relationKey['localKey']) . ') AS subquery )' ); @@ -280,12 +296,11 @@ private function retrieveRelateData(QueryBuilder $query) private function retrievePivotRelateData($relationName, QueryBuilder $relationQuery, QueryBuilder $query) { [$pivot, $pivotRef, $bucketAlias] = $this->applyPivotSelectAndJoin($relationQuery); - $parentQuery = clone $query; $relationQuery->whereRaw( $pivotRef . '.' . $pivot['foreignPivotKey'] . ' IN ( SELECT * FROM (' - . $parentQuery->select($pivot['parentKey'])->prepare() + . $this->prepareKeySubquery($query, $pivot['parentKey']) . ') AS subquery )' ); diff --git a/tests/EagerLoadIntegrationTest.php b/tests/EagerLoadIntegrationTest.php index 16a0828..0c45a06 100644 --- a/tests/EagerLoadIntegrationTest.php +++ b/tests/EagerLoadIntegrationTest.php @@ -52,4 +52,22 @@ public function testStaticWithEagerLoadsAndGroupsRelatedRows(): void $this->assertCount(1, $second->posts, 'user 2 should have 1 post'); $this->assertInstanceOf(Post::class, $first->posts[0]); } + + /** + * The parent's selectRaw must NOT leak into the eager key subquery — that + * subquery feeds an IN (...) and must return exactly one column (the localKey), + * else MySQL raises "Operand should contain 1 column(s)". + */ + public function testEagerLoadKeySubqueryIgnoresParentSelectRaw(): void + { + User::select(['id'])->selectRaw('CONCAT("X", id) as cx')->with('posts')->get(); + + $postsSql = $GLOBALS['wpdb']->queries[1]; + + $this->assertStringNotContainsString('CONCAT', $postsSql); + $this->assertStringContainsString( + 'IN ( SELECT * FROM (SELECT `wp_users`.`id` FROM wp_users', + $postsSql + ); + } } From acc365227a9b0d7afd955abd4ce822d51a9d2619 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Tue, 30 Jun 2026 19:02:38 +0600 Subject: [PATCH 077/102] fix: drop ORDER BY from eager-load key subquery (keep when LIMIT-bound) The `foreignKey IN ( SELECT key ... )` eager subquery cloned the parent and stripped selectRaw but kept ORDER BY. A parent ordered by a selectRaw alias (orderBy('dsc') after selectRaw('... as dsc')) then referenced a column absent from the narrowed subquery -> "Unknown column in order clause"; and ORDER BY is meaningless for set membership regardless. Move prepareKeySubquery onto QueryBuilder (so it can reset the protected orderBy) and drop ORDER BY unless a LIMIT pins which parent rows the set comes from. Byte-identical for parents without selectRaw/orderBy. Assisted-By: AI --- src/Concerns/Relations.php | 21 ++------------------- src/QueryBuilder.php | 22 ++++++++++++++++++++++ tests/EagerLoadIntegrationTest.php | 29 +++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 19 deletions(-) diff --git a/src/Concerns/Relations.php b/src/Concerns/Relations.php index 4012c42..71d2533 100644 --- a/src/Concerns/Relations.php +++ b/src/Concerns/Relations.php @@ -243,23 +243,6 @@ private function getRelationKeys($foreignKey, $localKey) return [$foreignKey, $localKey]; } - /** - * Compiles the parent query as a single-column key subquery for a relation's - * IN (...) constraint. Strips the parent's selectRaw — that subquery must - * return exactly the key column, not the caller's extra raw expressions. - * - * @param string $keyColumn - * - * @return string - */ - private function prepareKeySubquery(QueryBuilder $query, $keyColumn): string - { - $keyQuery = clone $query; - $keyQuery->selectRaw = ['columns' => [], 'bindings' => []]; - - return $keyQuery->select($keyColumn)->prepare(); - } - private function retrieveRelateData(QueryBuilder $query) { $relations = $this->getRelations(); @@ -277,7 +260,7 @@ private function retrieveRelateData(QueryBuilder $query) $relationQuery->whereRaw( $relationKey['foreignKey'] . ' IN ( SELECT * FROM (' - . $this->prepareKeySubquery($query, $relationKey['localKey']) + . $query->prepareKeySubquery($relationKey['localKey']) . ') AS subquery )' ); @@ -300,7 +283,7 @@ private function retrievePivotRelateData($relationName, QueryBuilder $relationQu $relationQuery->whereRaw( $pivotRef . '.' . $pivot['foreignPivotKey'] . ' IN ( SELECT * FROM (' - . $this->prepareKeySubquery($query, $pivot['parentKey']) + . $query->prepareKeySubquery($pivot['parentKey']) . ') AS subquery )' ); diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index 0c6b45e..9dac705 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -396,6 +396,28 @@ public function select($columns = ['*']) return $this; } + /** + * Compiles this query as a single-column key subquery for a relation's + * IN (...) constraint: only $keyColumn is projected. Strips selectRaw (extra + * columns would break the operand count) and, unless a LIMIT pins the set, + * ORDER BY (meaningless for set membership and may reference a stripped raw + * select alias). + * + * @param string $keyColumn + * + * @return string + */ + public function prepareKeySubquery($keyColumn): string + { + $clone = clone $this; + $clone->selectRaw = ['columns' => [], 'bindings' => []]; + if (!isset($clone->limit)) { + $clone->orderBy = []; + } + + return $clone->select($keyColumn)->prepare(); + } + /** * Adds column to select list * diff --git a/tests/EagerLoadIntegrationTest.php b/tests/EagerLoadIntegrationTest.php index 0c45a06..24f7bf2 100644 --- a/tests/EagerLoadIntegrationTest.php +++ b/tests/EagerLoadIntegrationTest.php @@ -70,4 +70,33 @@ public function testEagerLoadKeySubqueryIgnoresParentSelectRaw(): void $postsSql ); } + + /** + * ORDER BY is meaningless in a value-list IN ( SELECT key ... ) and may + * reference a stripped selectRaw alias — so it is dropped from the key + * subquery when no LIMIT pins the set. + */ + public function testEagerLoadKeySubqueryDropsParentOrderBy(): void + { + User::select(['id'])->selectRaw('CONCAT("X", id) as cx')->orderBy('cx', 'DESC')->with('posts')->get(); + + $postsSql = $GLOBALS['wpdb']->queries[1]; + + $this->assertStringNotContainsString('ORDER BY', $postsSql); + $this->assertStringNotContainsString('cx', $postsSql); + } + + /** + * When a LIMIT pins which parent rows the set comes from, ORDER BY is kept + * so the limited set stays deterministic. + */ + public function testEagerLoadKeySubqueryKeepsOrderByWhenLimited(): void + { + User::orderBy('id', 'DESC')->take(5)->with('posts')->get(); + + $postsSql = $GLOBALS['wpdb']->queries[1]; + + $this->assertStringContainsString('ORDER BY', $postsSql); + $this->assertStringContainsString('LIMIT 5', $postsSql); + } } From 234c80509425e71e33cb076690ac43028056ff39 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Tue, 30 Jun 2026 19:25:55 +0600 Subject: [PATCH 078/102] test: edge-case characterization for where/select/join/aggregate/relations 41 tests locking verified current behavior (no source change): - WhereClauseEdgeTest: whereIn type-placeholders, implicit-IN array value, IS NULL, falsy values not dropped, operator/LIKE variants, whereBetween, nested-closure binding order, whereRaw/selectRaw/having binding order, soft-delete scope binding order through the paren-wrap. - SelectJoinAggregateEdgeTest: column-alias variants (dotted/lowercase/ backticked/spaced/multi-as), RIGHT/FULL/CROSS join, on()/orOn() chaining, custom-$prefix join ON qualification, count()=COUNT(pk), max/min empty=null, aggregate-runs-on-clone, asc() pk fallback. - RelationEagerEdgeTest: eager/whereHas/withCount constraint closures, multiple whereHas, withCount alias, select+withCount no dup, relation alias attach, parent filter in eager subquery, eager-on-first(). Assisted-By: AI --- tests/RelationEagerEdgeTest.php | 135 +++++++++++++++++++++ tests/SelectJoinAggregateEdgeTest.php | 152 +++++++++++++++++++++++ tests/WhereClauseEdgeTest.php | 166 ++++++++++++++++++++++++++ 3 files changed, 453 insertions(+) create mode 100644 tests/RelationEagerEdgeTest.php create mode 100644 tests/SelectJoinAggregateEdgeTest.php create mode 100644 tests/WhereClauseEdgeTest.php diff --git a/tests/RelationEagerEdgeTest.php b/tests/RelationEagerEdgeTest.php new file mode 100644 index 0000000..0e22a76 --- /dev/null +++ b/tests/RelationEagerEdgeTest.php @@ -0,0 +1,135 @@ + 10, 'user_id' => 1], + (object) ['id' => 11, 'user_id' => 1], + (object) ['id' => 12, 'user_id' => 2], + ]; + } + + return [(object) ['id' => 1], (object) ['id' => 2]]; + }; + } + + public function testWhereHasClosureConstrainsExistsSubquery(): void + { + $sql = User::whereHas('posts', static function ($q) { + $q->where('status', 'published'); + })->toSql(); + + $this->assertStringContainsString('exists(', $sql); + $this->assertStringContainsString('`wp_posts`.`status`', $sql); + $this->assertStringContainsString('`wp_users`.`id`=`wp_posts`.`user_id`', $sql); + } + + public function testMultipleWhereHasAreAnded(): void + { + $sql = User::whereHas('posts')->whereHas('posts')->toSql(); + + $this->assertSame(2, substr_count($sql, 'exists(')); + } + + public function testWithCountClosureNarrowsCountSubquery(): void + { + $sql = User::withCount(['posts' => static function ($q) { + $q->where('status', 'published'); + }])->toSql(); + + $this->assertStringContainsString('count(*)', $sql); + $this->assertStringContainsString('`wp_posts`.`status`', $sql); + $this->assertStringContainsString('as `posts_count`', $sql); + } + + public function testWithCountAliasNamesTheColumn(): void + { + $this->assertStringContainsString('as `c`', User::withCount('posts as c')->toSql()); + } + + public function testSelectThenWithCountDoesNotDuplicateBaseSelect(): void + { + $sql = User::select(['id'])->withCount('posts')->toSql(); + + $this->assertStringContainsString('`wp_users`.`id`, (SELECT count(*)', $sql); + $this->assertStringNotContainsString('`wp_users`.*', $sql); + } + + public function testEagerClosureConstraintFiltersTheRelationQuery(): void + { + $GLOBALS['wpdb']->resolver = $this->eagerResolver(); + + User::with(['posts' => static function ($q) { + $q->where('status', 'published'); + }])->get(); + + $postsSql = $GLOBALS['wpdb']->queries[1]; + + $this->assertStringContainsString('`wp_posts`.`status`', $postsSql); + $this->assertStringContainsString('IN ( SELECT * FROM (', $postsSql); + } + + public function testParentWherePropagatesIntoEagerSubquery(): void + { + $GLOBALS['wpdb']->resolver = $this->eagerResolver(); + + User::where('status', 'active')->with('posts')->get(); + + $postsSql = $GLOBALS['wpdb']->queries[1]; + + $this->assertStringContainsString('`wp_users`.`status`', $postsSql); + $this->assertStringContainsString('AS subquery', $postsSql); + } + + public function testEagerRelationAliasAttachesUnderAlias(): void + { + $GLOBALS['wpdb']->resolver = $this->eagerResolver(); + + $users = User::with('posts as recent')->get(); + + // eager-attached relations are plain arrays (lazy access returns a Collection). + $this->assertCount(2, $users[0]->recent); + $this->assertSame(10, $users[0]->recent[0]->id); + } + + public function testEagerLoadOnFirstAttachesRelation(): void + { + $GLOBALS['wpdb']->resolver = static function ($sql) { + if (strpos($sql, 'wp_posts') !== false) { + return [(object) ['id' => 10, 'user_id' => 1]]; + } + + return [(object) ['id' => 1]]; + }; + + $user = User::with('posts')->where('id', 1)->first(); + + $this->assertCount(1, $user->posts); + $this->assertSame(10, $user->posts[0]->id); + } +} diff --git a/tests/SelectJoinAggregateEdgeTest.php b/tests/SelectJoinAggregateEdgeTest.php new file mode 100644 index 0000000..089d97a --- /dev/null +++ b/tests/SelectJoinAggregateEdgeTest.php @@ -0,0 +1,152 @@ +assertStringContainsString('t.col AS `a`', (new User())->select(['t.col AS a'])->toSql()); + } + + public function testLowercaseAsNormalisesToUppercase(): void + { + $this->assertStringContainsString('`wp_users`.`col` AS `a`', (new User())->select(['col as a'])->toSql()); + } + + public function testAlreadyBacktickedAliasIsNotDoubleQuoted(): void + { + $this->assertStringContainsString('`wp_users`.`col` AS `a`', (new User())->select(['col AS `a`'])->toSql()); + } + + public function testAliasWithSpacesIsBacktickedWhole(): void + { + $this->assertStringContainsString('`wp_users`.`col` AS `the alias`', (new User())->select(['col AS the alias'])->toSql()); + } + + public function testMultipleAsSplitsOnFirst(): void + { + $this->assertStringContainsString('`wp_users`.`a` AS `b as c`', (new User())->select(['a as b as c'])->toSql()); + } + + public function testSelectThenSelectRawJoinedByComma(): void + { + $this->assertStringContainsString('`wp_users`.`id`, COUNT(*) as c', (new User())->select(['id'])->selectRaw('COUNT(*) as c')->toSql()); + } + + public function testEmptySelectEmitsQualifiedNothing(): void + { + $this->assertStringContainsString('SELECT FROM wp_users', (new User())->toSql()); + } + + public function testPrepareColumnNameStarStaysQualifiedStar(): void + { + $this->assertSame('`wp_users`.*', (new User())->prepareColumnName('*')); + } + + // --- Joins --------------------------------------------------------------- + + public function testRightFullCrossJoinKeywords(): void + { + $this->assertStringContainsString('RIGHT JOIN wp_posts', (new User())->rightJoin('posts', 'posts.user_id', '=', 'users.id')->toSql()); + $this->assertStringContainsString('FULL JOIN wp_posts', (new User())->fullJoin('posts', 'posts.user_id', '=', 'users.id')->toSql()); + $this->assertStringContainsString('CROSS JOIN wp_posts', (new User())->crossJoin('posts', 'posts.user_id', '=', 'users.id')->toSql()); + } + + public function testOnAndOrOnAppendToSameJoin(): void + { + $and = (new User())->join('posts', 'posts.user_id', '=', 'users.id')->on('posts.status', '=', 'users.state')->toSql(); + $this->assertStringContainsString('posts.user_id = users.id AND posts.status = users.state', $and); + + $or = (new User())->join('posts', 'posts.user_id', '=', 'users.id')->orOn('posts.status', '=', 'users.state')->toSql(); + $this->assertStringContainsString('posts.user_id = users.id OR posts.status = users.state', $or); + } + + public function testJoinOnCustomPrefixModelQualifiesBaseColumnWithFullPrefix(): void + { + $sql = (new PrefixedModel())->join('gadgets', 'gid', '=', 'wid')->toSql(); + + $this->assertStringContainsString('INNER JOIN wp_crm_gadgets', $sql); + $this->assertStringContainsString('`wp_crm_widgets`.`gid` = wp_crm_gadgets.wid', $sql); + } + + // --- Aggregates / ordering ---------------------------------------------- + + public function testCountCompilesCountOfQualifiedPrimaryKey(): void + { + $GLOBALS['wpdb']->resolver = static function () { + return [(object) ['COUNT' => '7']]; + }; + + $result = (new User())->count(); + + $this->assertSame(7, $result); + $this->assertStringContainsString('COUNT(`wp_users`.`id`) as COUNT', $GLOBALS['wpdb']->last_query); + } + + public function testMaxAndMinReturnNullOnEmptyResultSet(): void + { + $GLOBALS['wpdb']->resolver = static function () { + return []; + }; + + $this->assertNull((new User())->max('score')); + $this->assertNull((new User())->min('score')); + } + + public function testAggregateRunsOnCloneWithoutMutatingSelect(): void + { + $GLOBALS['wpdb']->resolver = static function () { + return [(object) ['MAX' => 9]]; + }; + + $query = (new User())->select(['id', 'name']); + $before = $query->toSql(); + $query->max('score'); + + $this->assertSame($before, $query->toSql()); + } + + public function testAggregateCarriesWhereClause(): void + { + $GLOBALS['wpdb']->resolver = static function () { + return [(object) ['COUNT' => '3']]; + }; + + (new User())->where('active', 1)->count(); + + $this->assertStringContainsString('WHERE', $GLOBALS['wpdb']->last_query); + $this->assertStringContainsString('`wp_users`.`active`', $GLOBALS['wpdb']->last_query); + } + + public function testAscFallsBackToPrimaryKey(): void + { + $this->assertStringContainsString('ORDER BY id ASC', (new User())->asc()->toSql()); + } + + public function testSkipWithoutTakeOmitsOffset(): void + { + $this->assertStringNotContainsString('OFFSET', (new User())->skip(20)->toSql()); + } +} diff --git a/tests/WhereClauseEdgeTest.php b/tests/WhereClauseEdgeTest.php new file mode 100644 index 0000000..9a7cdab --- /dev/null +++ b/tests/WhereClauseEdgeTest.php @@ -0,0 +1,166 @@ +toSql(); + + return $qb->getBindings(); + } + + public function testWhereInTypesPlaceholdersPerElement(): void + { + $qb = (new User())->whereIn('id', [1, 'a', 2.5]); + + $this->assertStringContainsString('`wp_users`.`id` IN (%d,%s,%f)', $qb->toSql()); + $this->assertSame([1, 'a', 2.5], $qb->getBindings()); + } + + public function testWhereInSingleElement(): void + { + $qb = (new User())->whereIn('id', [5]); + + $this->assertStringContainsString('`wp_users`.`id` IN (%d)', $qb->toSql()); + $this->assertSame([5], $qb->getBindings()); + } + + public function testWhereInAssocDropsKeys(): void + { + $qb = (new User())->whereIn('id', ['x' => 1, 'y' => 2]); + + $this->assertStringContainsString('IN (%d,%d)', $qb->toSql()); + $this->assertSame([1, 2], $qb->getBindings()); + } + + public function testWhereWithArrayValueIsImplicitIn(): void + { + $qb = (new User())->where('id', [1, 2, 3]); + + // the 2-arg array path produces "IN (" (two spaces) — distinct from whereIn's "IN (". + $this->assertStringContainsString('`wp_users`.`id` IN (%d,%d,%d)', $qb->toSql()); + $this->assertSame([1, 2, 3], $qb->getBindings()); + } + + public function testWhereNullEmitsIsNull(): void + { + $qb = (new User())->where('id', null); + + $this->assertStringContainsString('`wp_users`.`id` IS NULL', $qb->toSql()); + $this->assertSame([], $qb->getBindings()); + } + + public function testWhereNullAndNotNullHelpers(): void + { + $this->assertStringContainsString('`wp_users`.`deleted_at` IS NULL', (new User())->whereNull('deleted_at')->toSql()); + $this->assertStringContainsString('`wp_users`.`deleted_at` IS NOT NULL', (new User())->whereNotNull('deleted_at')->toSql()); + } + + public function testFalsyValuesAreNotDropped(): void + { + $this->assertSame([0], $this->bindings((new User())->where('id', 0))); + $this->assertSame([false], $this->bindings((new User())->where('active', false))); + $this->assertSame(['0'], $this->bindings((new User())->where('id', '0'))); + $this->assertSame([''], $this->bindings((new User())->where('id', ''))); + } + + public function testOperatorVariantsPassThrough(): void + { + $this->assertStringContainsString('`wp_users`.`id` != %d', (new User())->where('id', '!=', 5)->toSql()); + $this->assertStringContainsString('`wp_users`.`id` <> %d', (new User())->where('id', '<>', 5)->toSql()); + $this->assertStringContainsString('`wp_users`.`id` >= %d', (new User())->where('id', '>=', 5)->toSql()); + } + + public function testLikeUppercaseAndNotLike(): void + { + $this->assertStringContainsString('`wp_users`.`name` LIKE %s', (new User())->where('name', 'LIKE', '%a%')->toSql()); + $this->assertStringContainsString('`wp_users`.`name` NOT LIKE %s', (new User())->where('name', 'NOT LIKE', '%a%')->toSql()); + } + + public function testWhereBetweenAndOrWhereBetween(): void + { + $qb = (new User())->whereBetween('age', 18, 65); + $this->assertStringContainsString('(age BETWEEN %d AND %d)', $qb->toSql()); + $this->assertSame([18, 65], $qb->getBindings()); + + $qb2 = (new User())->where('id', 1)->orWhereBetween('age', 18, 65); + $this->assertStringContainsString('OR (age BETWEEN %d AND %d)', $qb2->toSql()); + $this->assertSame([1, 18, 65], $qb2->getBindings()); + } + + public function testNestedClosureFirstReordersBindingsToPlaceholderOrder(): void + { + $qb = (new User()) + ->where(static function ($q) { + $q->where('b', 2)->orWhere('c', 3); + }) + ->where('a', 1); + + $this->assertStringContainsString('( `wp_users`.`b` = %d OR `wp_users`.`c` = %d) AND `wp_users`.`a` = %d', $qb->toSql()); + $this->assertSame([2, 3, 1], $qb->getBindings()); + } + + public function testNestedClosureWithExplicitOrBool(): void + { + $qb = (new User()) + ->where('a', 1) + ->where(static function ($q) { + $q->where('b', 2); + }, 'OR'); + + $this->assertStringContainsString('`wp_users`.`a` = %d OR ( `wp_users`.`b` = %d)', $qb->toSql()); + $this->assertSame([1, 2], $qb->getBindings()); + } + + public function testWhereRawBindingOrder(): void + { + $this->assertSame([99, 5], $this->bindings((new User())->whereRaw('x = %d', [99])->where('id', 5))); + $this->assertSame([5, 99], $this->bindings((new User())->where('id', 5)->whereRaw('x = %d', [99]))); + $this->assertSame([5, 99], $this->bindings((new User())->where('id', 5)->orWhereRaw('x = %d', [99]))); + } + + public function testSelectRawThenWhereThenHavingBindingOrder(): void + { + $qb = (new User()) + ->selectRaw('%s as label', ['L']) + ->where('id', 5) + ->groupBy('status') + ->having('cnt', '>', 3); + + $this->assertSame(['L', 5, 3], $this->bindings($qb)); + } + + public function testSoftDeleteScopeKeepsUserBindingOrderThroughParenWrap(): void + { + $qb = ScopedSoftPost::query()->where('a', 1)->orWhere('b', 2); + + $sql = $qb->toSql(); + $this->assertStringContainsString('( `wp_scoped_soft_posts`.`a` = %d OR `wp_scoped_soft_posts`.`b` = %d)', $sql); + $this->assertStringContainsString('`deleted_at` IS NULL', $sql); + $this->assertSame([1, 2], $qb->getBindings()); + } +} From 42ba9b99d2d16bfb2923c26f5e216e6b533c5353 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Tue, 30 Jun 2026 23:25:24 +0600 Subject: [PATCH 079/102] fix: repair insert/where/aggregate/soft-delete edge cases (zero-BC Phase 1) Pure repairs of crashing / invalid-SQL paths plus additive forceDelete/restore; byte-identical for every currently-working input (momus-reviewed). - insert(): route to bulk only for a true list-of-rows, so a single row whose first value is an array no longer crashes via ksort(). - where/whereIn: null + explicit operator -> IS [NOT] NULL; empty array -> the false constant `0 = 1` (was invalid `IN ()`); a nested IN element -> one JSON-encoded placeholder; object value -> wp_json_encode (was fatal on prepare). - empty save() (no dirty columns) skips the malformed `UPDATE ... SET` and returns the model (fires saving/saved). - insert([]) / empty bulk rows / upsert($v, []) no longer emit malformed SQL. - aggregate(fn, '*') -> COUNT(*) (was invalid COUNT(`t`.*)); count() unchanged. - forceDelete() (real DELETE bypassing the soft scope) and restore() on soft-delete models. - take()/skip() cast to int -> blocks limit/offset injection. - eager key subquery also strips the parent's groupBy/having. Assisted-By: AI --- src/Model.php | 2 + src/QueryBuilder.php | 184 ++++++++++++++++++++++++- tests/EagerKeySubqueryScopeFixTest.php | 43 ++++++ tests/QueryClauseEdgeFixTest.php | 110 +++++++++++++++ tests/SoftDeleteRestoreTest.php | 51 +++++++ tests/WriteEdgeFixTest.php | 111 +++++++++++++++ 6 files changed, 494 insertions(+), 7 deletions(-) create mode 100644 tests/EagerKeySubqueryScopeFixTest.php create mode 100644 tests/QueryClauseEdgeFixTest.php create mode 100644 tests/SoftDeleteRestoreTest.php create mode 100644 tests/WriteEdgeFixTest.php diff --git a/src/Model.php b/src/Model.php index b0c05f3..acd4fbe 100644 --- a/src/Model.php +++ b/src/Model.php @@ -86,6 +86,8 @@ * @method static mixed max($column) * @method static mixed min($column) * @method static bool|string delete() + * @method static string|bool forceDelete() + * @method static string|bool|Model restore() * @method static string toSql() * @method static string prepare($sql = null) * @method static QueryBuilder withTrashed() diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index 9dac705..b075542 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -81,6 +81,8 @@ class QueryBuilder private $_onlyTrashed = false; + private $_forceDelete = false; + /** * Constructs QueryBuilder * @@ -411,6 +413,8 @@ public function prepareKeySubquery($keyColumn): string { $clone = clone $this; $clone->selectRaw = ['columns' => [], 'bindings' => []]; + $clone->groupBy = []; + $clone->having = []; if (!isset($clone->limit)) { $clone->orderBy = []; } @@ -600,6 +604,14 @@ public function orWhereRaw($sql, $bindings = []) */ public function whereIn($column, $value) { + if (\is_array($value)) { + if ($value === []) { + return $this->whereRaw('0 = 1'); + } + + $value = $this->sanitizeInValues($value); + } + $this->where[] = [ 'column' => $column, 'value' => $value, @@ -1023,7 +1035,7 @@ public function prepareRaw() */ public function take($count) { - $this->limit = $count; + $this->limit = (int) $count; return $this; } @@ -1037,7 +1049,7 @@ public function take($count) */ public function skip($count) { - $this->offset = $count; + $this->offset = (int) $count; return $this; } @@ -1051,7 +1063,11 @@ public function skip($count) */ public function insert($attributes = []) { - if (\is_array(reset($attributes))) { + if (empty($attributes)) { + return false; + } + + if ($this->isListOfRows($attributes)) { return $this->bulkInsert($attributes); } @@ -1111,6 +1127,12 @@ public function save() $columns = $this->prepareAttributeForSaveOrUpdate($this->_model->exists()); $pk = $this->_model->getPrimaryKey(); if ($this->_model->exists()) { + if (empty($columns)) { + $this->_model->fireEvent('saved'); + + return $this->_model; + } + $isPkExistsInWhere = false; $pkValue = $this->_model->getAttribute($pk); @@ -1172,7 +1194,8 @@ public function aggregate($function, $column) $query = $this->clone(); $query->select = []; $query->selectRaw = ['columns' => [], 'bindings' => []]; - $result = $query->selectRaw($function . '(' . $query->prepareColumnName($column) . ') as ' . $function)->exec(); + $preparedColumn = $column === '*' ? '*' : $query->prepareColumnName($column); + $result = $query->selectRaw($function . '(' . $preparedColumn . ') as ' . $function)->exec(); return \is_array($result) && isset($result[0]->{$function}) ? $result[0]->{$function} : null; } @@ -1187,6 +1210,37 @@ public function delete() return $this->exec(); } + /** + * Permanently deletes the targeted rows of a soft-delete model, bypassing + * the soft-delete rewrite (emits a real DELETE). + * + * @return string|bool + */ + public function forceDelete() + { + if (!$this->isSoftDeleteModel()) { + throw new RuntimeException('forceDelete() is only available on soft-delete models.'); + } + + $this->_forceDelete = true; + + return $this->delete(); + } + + /** + * Restores soft-deleted rows by nulling deleted_at and persisting. + * + * @return string|bool|Model + */ + public function restore() + { + if (!$this->isSoftDeleteModel()) { + throw new RuntimeException('restore() is only available on soft-delete models.'); + } + + return $this->update(['deleted_at' => null]); + } + /** * Starts transaction * @@ -1299,7 +1353,7 @@ public function upsert(array $values, ?array $update = null) $values = [$values]; } - if (\is_null($update)) { + if (empty($update)) { $update = array_keys($values[0]); } @@ -1442,7 +1496,7 @@ protected function prepareConditional($params, $bool = 'AND', $type = 'where') $conditions['bool'] = $params[3]; } - return $conditions; + return $this->normalizeConditions($conditions, $type); } /** @@ -1540,6 +1594,118 @@ protected function getTimeZone() return $timezoneString; } + /** + * Coerces each IN-list element to a scalar so it maps to exactly one + * placeholder and one binding (a nested array/object is JSON-encoded, never + * flattened into multiple bindings). + * + * @param array $values + * + * @return array + */ + private function sanitizeInValues(array $values) + { + return array_map( + function ($value) { + if (\is_array($value) || \is_object($value)) { + return wp_json_encode($value); + } + + return $value; + }, + $values + ); + } + + /** + * True only when $attributes is a non-empty positional list whose every + * element is itself an array (a list of rows for bulk insert). An assoc row + * whose first value happens to be an array must take the single-row path. + * + * @param mixed $attributes + * + * @return bool + */ + private function isListOfRows($attributes) + { + if (!\is_array($attributes) || $attributes === []) { + return false; + } + + if (array_keys($attributes) !== range(0, \count($attributes) - 1)) { + return false; + } + + foreach ($attributes as $row) { + if (!\is_array($row)) { + return false; + } + } + + return true; + } + + /** + * Repairs where-clause values that would otherwise emit invalid SQL or fatal + * on prepare, independent of arity: an empty array becomes the false + * constant `0 = 1`; a null value with an explicit operator becomes + * IS [NOT] NULL; a non-empty array has its elements coerced to scalars; an + * object value is JSON-encoded. Having clauses are left untouched. + * + * @param array $conditions + * @param string $type + * + * @return array + */ + private function normalizeConditions(array $conditions, $type) + { + if ($type !== 'where' || !\array_key_exists('value', $conditions)) { + return $conditions; + } + + $value = $conditions['value']; + $bool = isset($conditions['bool']) ? $conditions['bool'] : 'AND'; + + if (\is_array($value)) { + if ($value === []) { + return ['bool' => $bool, 'raw' => '0 = 1', 'bindings' => []]; + } + + $conditions['value'] = $this->sanitizeInValues($value); + + return $conditions; + } + + if (\is_null($value)) { + if (isset($conditions['operator'])) { + unset($conditions['value']); + $conditions['operator'] = $this->nullOperator($conditions['operator']); + } + + return $conditions; + } + + if (\is_object($value)) { + $conditions['value'] = wp_json_encode($value); + } + + return $conditions; + } + + /** + * Maps a comparison operator to its null-safe form for a null value. + * + * @param string $operator + * + * @return string + */ + private function nullOperator($operator) + { + $negations = ['!=', '<>', 'NOT', 'IS NOT', 'IS NOT NULL']; + + return \in_array($operator, $negations, true) ? 'IS NOT NULL' : 'IS NULL'; + } + /** * Returns true when the model declares soft-delete support. * @@ -1570,6 +1736,10 @@ private function autoScopeEnabled() private function bulkInsert($attributes) { $firstRow = reset($attributes); + if (empty($firstRow)) { + return new Collection([]); + } + ksort($firstRow); $columns = array_keys($firstRow); $createdAt = property_exists($this->_model, 'timestamps') && $this->_model->timestamps; @@ -1758,7 +1928,7 @@ private function prepareDelete() return ''; } - if (property_exists($this->_model, 'soft_deletes') && $this->_model->soft_deletes) { + if (!$this->_forceDelete && property_exists($this->_model, 'soft_deletes') && $this->_model->soft_deletes) { $timestamp = $this->currentTimestamp(); array_unshift($this->bindings, $timestamp); diff --git a/tests/EagerKeySubqueryScopeFixTest.php b/tests/EagerKeySubqueryScopeFixTest.php new file mode 100644 index 0000000..45b54be --- /dev/null +++ b/tests/EagerKeySubqueryScopeFixTest.php @@ -0,0 +1,43 @@ +resolver = static function ($sql) { + if (strpos($sql, 'wp_posts') !== false) { + return [(object) ['id' => 10, 'user_id' => 1]]; + } + + return [(object) ['id' => 1]]; + }; + } + + protected function tearDown(): void + { + $GLOBALS['wpdb'] = new FakeWpdb(); + } + + public function testEagerKeySubqueryDropsParentGroupByAndHaving(): void + { + User::groupBy('status')->having('cnt', '>', 1)->with('posts')->get(); + + $postsSql = $GLOBALS['wpdb']->queries[1]; + + $this->assertStringNotContainsString('GROUP BY', $postsSql); + $this->assertStringNotContainsString('HAVING', $postsSql); + $this->assertStringNotContainsString('cnt', $postsSql); + } +} diff --git a/tests/QueryClauseEdgeFixTest.php b/tests/QueryClauseEdgeFixTest.php new file mode 100644 index 0000000..e475a79 --- /dev/null +++ b/tests/QueryClauseEdgeFixTest.php @@ -0,0 +1,110 @@ +where('id', '=', null); + + $this->assertStringContainsString('`wp_users`.`id` IS NULL', $qb->toSql()); + $qb->toSql(); + $this->assertSame([], $qb->getBindings()); + } + + public function testWhereWithNotEqualNullEmitsIsNotNull(): void + { + $this->assertStringContainsString( + '`wp_users`.`id` IS NOT NULL', + (new User())->where('id', '!=', null)->toSql() + ); + } + + // A3 --------------------------------------------------------------------- + + public function testWhereInEmptyArrayEmitsFalseConstant(): void + { + $qb = (new User())->whereIn('id', []); + + $sql = $qb->toSql(); + $this->assertStringContainsString('0 = 1', $sql); + $this->assertStringNotContainsString('IN ()', $sql); + $this->assertSame([], $qb->getBindings()); + } + + public function testWhereWithEmptyArrayValueEmitsFalseConstant(): void + { + $sql = (new User())->where('status', [])->toSql(); + + $this->assertStringContainsString('0 = 1', $sql); + $this->assertStringNotContainsString('IN ()', $sql); + } + + // A8 --------------------------------------------------------------------- + + public function testWhereInNestedArrayElementGetsOnePlaceholderEach(): void + { + $qb = (new User())->whereIn('id', [[1, 2], 3]); + + $this->assertStringContainsString('IN (%s,%d)', $qb->toSql()); + $this->assertSame(['[1,2]', 3], $qb->getBindings()); + } + + // B3 --------------------------------------------------------------------- + + public function testWhereWithObjectValueBindsJsonString(): void + { + $qb = (new User())->where('meta', (object) ['k' => 'v']); + + $this->assertStringContainsString('`wp_users`.`meta` = %s', $qb->toSql()); + $qb->toSql(); + $this->assertSame(['{"k":"v"}'], $qb->getBindings()); + } + + // E1 --------------------------------------------------------------------- + + public function testTakeCastsArgumentToIntBlockingInjection(): void + { + $sql = (new User())->take('5; DROP TABLE wp_users')->toSql(); + + $this->assertStringContainsString('LIMIT 5', $sql); + $this->assertStringNotContainsString('DROP', $sql); + } + + public function testTakeNumericIsByteIdentical(): void + { + $this->assertStringContainsString('LIMIT 10', (new User())->take(10)->toSql()); + } + + public function testSkipCastsArgumentToInt(): void + { + $sql = (new User())->take(10)->skip('20; DROP')->toSql(); + + $this->assertStringContainsString('OFFSET 20', $sql); + $this->assertStringNotContainsString('DROP', $sql); + } +} diff --git a/tests/SoftDeleteRestoreTest.php b/tests/SoftDeleteRestoreTest.php new file mode 100644 index 0000000..2277be8 --- /dev/null +++ b/tests/SoftDeleteRestoreTest.php @@ -0,0 +1,51 @@ +forceDelete(); + + $sql = $GLOBALS['wpdb']->last_query; + $this->assertStringContainsString('DELETE FROM wp_soft_posts', $sql); + $this->assertStringNotContainsString('deleted_at', $sql); + } + + public function testRestoreNullsDeletedAt(): void + { + SoftPost::where('id', 1)->restore(); + + $sql = $GLOBALS['wpdb']->last_query; + $this->assertMatchesRegularExpression('/UPDATE\s+wp_soft_posts\s+SET\s+deleted_at\s*=\s*NULL/i', $sql); + } + + public function testForceDeleteRejectsNonSoftDeleteModel(): void + { + $this->expectException(RuntimeException::class); + + User::where('id', 1)->forceDelete(); + } +} diff --git a/tests/WriteEdgeFixTest.php b/tests/WriteEdgeFixTest.php new file mode 100644 index 0000000..0ecdc10 --- /dev/null +++ b/tests/WriteEdgeFixTest.php @@ -0,0 +1,111 @@ +insert_id = 1; + + $result = User::query()->insert(['tags' => ['a'], 'name' => 'x']); + + $this->assertInstanceOf(User::class, $result); + + $sql = $GLOBALS['wpdb']->last_query; + $this->assertStringStartsWith('INSERT INTO wp_users', $sql); + $this->assertStringContainsString('["a"]', $sql, 'tags must be JSON-encoded'); + $this->assertStringNotContainsString('),', $sql, 'must be a single VALUES tuple, not bulk'); + } + + // A5 --------------------------------------------------------------------- + + public function testEmptySaveSkipsMalformedUpdateAndReturnsModel(): void + { + $GLOBALS['wpdb']->resolver = static function () { + return [(object) ['id' => 1, 'name' => 'Ada']]; + }; + + $user = User::query()->where('id', 1)->first(); + + $GLOBALS['wpdb']->queries = []; + $GLOBALS['wpdb']->last_query = ''; + + $result = $user->save(); + + $this->assertSame($user, $result); + $this->assertSame([], $GLOBALS['wpdb']->queries, 'no UPDATE … SET query may be emitted'); + } + + // A6 --------------------------------------------------------------------- + + public function testInsertEmptyArrayReturnsFalseWithoutQuery(): void + { + $result = User::query()->insert([]); + + $this->assertFalse($result); + $this->assertSame([], $GLOBALS['wpdb']->queries); + } + + public function testBulkInsertEmptyRowsReturnsEmptyCollection(): void + { + $result = User::query()->insert([[], []]); + + $this->assertInstanceOf(Collection::class, $result); + $this->assertCount(0, $result); + $this->assertSame([], $GLOBALS['wpdb']->queries); + } + + public function testUpsertEmptyUpdateListDefaultsToAllColumns(): void + { + User::query()->upsert(['email' => 'a@x.com'], []); + + $sql = $GLOBALS['wpdb']->last_query; + $this->assertStringContainsString('email = VALUES(email)', $sql); + $this->assertStringNotContainsString('UPDATE ;', $sql); + } + + // A7 --------------------------------------------------------------------- + + public function testAggregateStarUsesBareStar(): void + { + (new User())->aggregate('COUNT', '*'); + + $sql = $GLOBALS['wpdb']->last_query; + $this->assertStringContainsString('COUNT(*)', $sql); + $this->assertStringNotContainsString('COUNT(`wp_users`.*)', $sql); + } + + public function testCountAggregateStillQualifiesPrimaryKey(): void + { + $GLOBALS['wpdb']->resolver = static function () { + return [(object) ['COUNT' => '3']]; + }; + + (new User())->count(); + + $this->assertStringContainsString('COUNT(`wp_users`.`id`)', $GLOBALS['wpdb']->last_query); + } +} From 018290c21975f72570c59d554006e384676e40e0 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Tue, 30 Jun 2026 23:26:51 +0600 Subject: [PATCH 080/102] docs: forceDelete/restore + Phase 1 invalid-SQL repair notes Assisted-By: AI --- docs/breaking-changes.md | 15 +++++++++++++++ docs/usage.md | 8 ++++++++ 2 files changed, 23 insertions(+) diff --git a/docs/breaking-changes.md b/docs/breaking-changes.md index d0f42f7..2c9a9de 100644 --- a/docs/breaking-changes.md +++ b/docs/breaking-changes.md @@ -323,6 +323,18 @@ Not signature breaks, but observable runtime differences. `wp_json_encode`, matching `save()`/`update()`. Previously a multi-row `insert([[...]])` or `upsert()` bound an array as the literal `"Array"` and an object threw — both repaired. Scalar values are unchanged. +- **Invalid-SQL / crash repairs (output changes only for previously-broken + input; working inputs are byte-identical):** `whereIn('c', [])` / `where('c', + [])` now emit `0 = 1` (was invalid `IN ()`); `where('c', '=', null)` and other + operator+null forms emit `IS [NOT] NULL` (was a truncated, value-less clause); + a `where`/`whereIn` value that is an object or a nested array is `wp_json_encode`d + (was a fatal / binding mismatch); `aggregate(fn, '*')` emits `COUNT(*)` (was + invalid `COUNT(\`t\`.*)`); an empty `save()` (no changed columns) skips the + query and returns the model (was a malformed `UPDATE … SET`); `insert([])`, + empty bulk rows, and `upsert($v, [])` no longer emit malformed SQL; a single + `insert()` row whose first value is an array no longer crashes. +- **`take()` / `skip()` cast their argument to `int`** — blocks `LIMIT`/`OFFSET` + injection; numeric input is byte-identical. - **`join()` table prefix corrected for custom-`$prefix` models.** Join (and pivot) tables now carry the model's **full** table prefix via the new `Model::getTablePrefix()` (`wp_` plus the plugin prefix), matching the model's @@ -426,6 +438,9 @@ User::query()->with('posts')->where('active', 1)->get(); `getPrefix()`, `getTablePrefix()` (full table prefix — `wp_` + plugin prefix — for join/pivot table names), `withCast(array $casts)`, `bool`/`boolean` cast. - **Connection:** `startTransaction()`, `commit()`, `rollback()`. +- **Model (soft-delete):** `forceDelete()` (real `DELETE`, bypasses the soft + rewrite) and `restore()` (clears `deleted_at`). Both throw on a non-soft-delete + model. - **Blueprint:** `unique($column = null)` — optional arg (backward compatible) for composite/explicit unique indexes. - **QueryBuilder:** `static $TIME_ZONE` to set the timezone statically; `$select` diff --git a/docs/usage.md b/docs/usage.md index 2433bd9..bf77c85 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -375,6 +375,14 @@ Contact::destroy([1, 2, 3]); // delete by primary keys `public $soft_delete_scope = true;` to enable automatic filtering: reads exclude trashed rows, `->withTrashed()` includes them, `->onlyTrashed()` returns only trashed rows. See [Limitations](#limitations--known-issues). +- On a soft-delete model, `forceDelete()` emits a real `DELETE` (bypassing the + soft rewrite) and `restore()` clears `deleted_at`. Both throw on a model + without `$soft_deletes`. + +```php +Contact::where('id', 1)->forceDelete(); // real DELETE, even for soft-delete models +Contact::onlyTrashed()->where('id', 1)->restore(); // deleted_at = NULL +``` --- From 3810f3b3c29ba9827b95fcd0ab43d4909a347ecf Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Wed, 1 Jul 2026 00:17:57 +0600 Subject: [PATCH 081/102] fix: order/group injection guard, cast aliases, relation-dirty, ragged bulk, schema DDL (zero-BC Phase 2) Behavior-changing fixes verified safe against bit-pi/bit-crm/bit-social/bit-assist usage (consumers pass only identifiers to order/group by, don't use the cast aliases, etc.); byte-identical for currently-working inputs (momus-reviewed). - E2: orderBy()/groupBy() validate the column is a plain identifier (`^[A-Za-z0-9_.`]+$`) and throw otherwise -> blocks ORDER BY/GROUP BY injection. Valid identifiers emit byte-identical SQL; raw expressions still go through orderByRaw(). - B1: cast aliases integer/float/double/json/datetime now map onto the real casters (were silent no-ops). - C1: relation values (Collection / Model / list-of-Model) are excluded from the save/update column set -> lazy relation access no longer corrupts a later save(); scalar/JSON-array columns still persist. - C6: bulk insert aligns each row to the header columns by key (was silent misalignment for ragged rows); uniform rows unchanged. - B4: Schema edit-mode unique() emits `ADD UNIQUE INDEX`; renameColumn() in edit() emits `RENAME COLUMN`; decimal($p, $s) works (incl. scale 0). Assisted-By: AI --- src/Blueprint.php | 33 ++++++++----- src/Model.php | 67 ++++++++++++++++++++++--- src/QueryBuilder.php | 57 +++++++++++++++++++-- tests/CastAliasFixTest.php | 60 ++++++++++++++++++++++ tests/Fixtures/CastAliasModel.php | 22 +++++++++ tests/OrderGroupInjectionFixTest.php | 67 +++++++++++++++++++++++++ tests/RaggedBulkInsertFixTest.php | 43 ++++++++++++++++ tests/RelationDirtyFixTest.php | 74 ++++++++++++++++++++++++++++ tests/SchemaDdlFixTest.php | 55 +++++++++++++++++++++ tests/bootstrap.php | 1 + 10 files changed, 456 insertions(+), 23 deletions(-) create mode 100644 tests/CastAliasFixTest.php create mode 100644 tests/Fixtures/CastAliasModel.php create mode 100644 tests/OrderGroupInjectionFixTest.php create mode 100644 tests/RaggedBulkInsertFixTest.php create mode 100644 tests/RelationDirtyFixTest.php create mode 100644 tests/SchemaDdlFixTest.php diff --git a/src/Blueprint.php b/src/Blueprint.php index 3186b77..4a93645 100644 --- a/src/Blueprint.php +++ b/src/Blueprint.php @@ -43,8 +43,8 @@ * @method Blueprint float($name, $length = null) * @method Blueprint double($name, $length = null) * @method Blueprint double_precision($name, $length = null) - * @method Blueprint decimal($name, $length = null) - * @method Blueprint dec($name, $length = null) + * @method Blueprint decimal($name, $precision = null, $scale = null) + * @method Blueprint dec($name, $precision = null, $scale = null) * @method Blueprint date($name) * @method Blueprint datetime($name) * @method Blueprint timestamp($name) @@ -116,7 +116,9 @@ public function __call($method, $parameters) { $formattedMethodName = strtoupper(str_replace('_', ' ', $method)); if ($this->isValidType($formattedMethodName)) { - if (\count($parameters) > 2) { + // Only decimal/dec take a scale (name, precision, scale); others are name + length. + $maxParams = \in_array($method, ['decimal', 'dec'], true) ? 3 : 2; + if (\count($parameters) > $maxParams) { throw new Exception('Too many parameters'); } @@ -204,6 +206,7 @@ public function edit() { $queryToAdd[] = $this->addColumnQuery(); $queryToAdd[] = $this->dropColumnQuery(); + $queryToAdd[] = $this->renameColumnQuery(); $queryToAdd = $queryToAdd + $this->_edit; $queryToAdd[] = $this->addPrimaryKeyQuery(); $queryToAdd[] = $this->addUniqueIndexQuery(); @@ -237,11 +240,13 @@ public function rename($newName) return $this; } - public function addColumn($name, $type, $length = null) + public function addColumn($name, $type, $length = null, $scale = null) { if ($this->method === 'addColumn') { $this->_sql = "ALTER TABLE {$this->table} ADD {$name} {$type}"; - if ($length) { + if (!\is_null($scale)) { + $this->_sql .= "({$length}, {$scale})"; + } elseif ($length) { $this->_sql .= "({$length})"; } } else { @@ -250,7 +255,10 @@ public function addColumn($name, $type, $length = null) 'name' => $name, 'type' => $type, ]; - if (!\is_null($length)) { + if (!\is_null($scale)) { + $this->columns[$this->columnIndex]['precision'] = $length; + $this->columns[$this->columnIndex]['scale'] = $scale; + } elseif (!\is_null($length)) { $this->length($length); } } @@ -272,7 +280,7 @@ public function dropColumn($column) public function renameColumn($column, $newName) { if ($this->method === 'renameColumn') { - $this->_sql = "ALTER TABLE {$this->table} CHANGE {$column} {$newName}"; + $this->_sql = "ALTER TABLE {$this->table} RENAME COLUMN {$column} TO {$newName}"; } else { $this->columnsToRename[] = [ 'column' => $column, @@ -292,7 +300,7 @@ public function renameColumnQuery() $query .= "\n, "; } - $query .= "CHANGE {$column['column']} {$column['new_name']}"; + $query .= "RENAME COLUMN {$column['column']} TO {$column['new_name']}"; } } @@ -637,7 +645,7 @@ private function addColumnQuery() $query .= $column['name'] . ' ' . $column['type']; if (!empty($column['length'])) { $query .= '(' . $column['length'] . ')'; - } elseif (!empty($column['precision']) && !empty($column['scale'])) { + } elseif (isset($column['precision'], $column['scale'])) { $query .= '(' . $column['precision'] . ', ' . $column['scale'] . ')'; } else { $query .= ' '; @@ -735,12 +743,13 @@ private function addUniqueIndexQuery() return ''; } - $query = ''; + $addPrefix = $this->method === 'edit' ? ' ADD ' : ''; + $query = ''; foreach ($this->uniqueIndex as $key => $uniqueColumn) { if (\is_array($uniqueColumn)) { - $query .= "\nUNIQUE INDEX " . implode('_', $uniqueColumn) . '_UNIQUE (' . implode(',', $uniqueColumn) . '),'; + $query .= "\n" . $addPrefix . 'UNIQUE INDEX ' . implode('_', $uniqueColumn) . '_UNIQUE (' . implode(',', $uniqueColumn) . '),'; } else { - $query .= "\nUNIQUE INDEX {$uniqueColumn}_UNIQUE ({$uniqueColumn} ASC),"; + $query .= "\n" . $addPrefix . "UNIQUE INDEX {$uniqueColumn}_UNIQUE ({$uniqueColumn} ASC),"; } } diff --git a/src/Model.php b/src/Model.php index acd4fbe..961df86 100644 --- a/src/Model.php +++ b/src/Model.php @@ -385,7 +385,33 @@ public function isDirty() public function getDirtyAttributes() { - return $this->dirty; + if (!\is_array($this->dirty)) { + return $this->dirty; + } + + return array_filter($this->dirty, function ($value) { + return !$this->isRelationValue($value); + }); + } + + /** + * True when a value is a loaded relation (a Collection, a related Model, or + * a list whose first element is one) rather than a persistable column value. + * A plain JSON array of scalars is NOT a relation and is still written. + * + * @param mixed $value + * + * @return bool + */ + public function isRelationValue($value) + { + if ($value instanceof Collection || $value instanceof Model) { + return true; + } + + return \is_array($value) + && isset($value[0]) + && ($value[0] instanceof Model || $value[0] instanceof Collection); } public function getOriginal() @@ -547,15 +573,37 @@ private function castTo($column, $value) return $value; } - if ( - !isset($this->casts) - || (isset($this->casts) && !isset($this->casts[$column])) - || !method_exists($this, 'castTo' . ucfirst($this->casts[$column])) - ) { + if (!isset($this->casts) || !isset($this->casts[$column])) { + return $value; + } + + $caster = $this->resolveCastMethod($this->casts[$column]); + if (!method_exists($this, $caster)) { return $value; } - return \call_user_func([$this, 'castTo' . ucfirst($this->casts[$column])], $value); + return \call_user_func([$this, $caster], $value); + } + + /** + * Resolves a cast name to its caster method, mapping the documented aliases + * (integer/float/double/json/datetime) onto the existing casters. + * + * @param string $cast + * + * @return string + */ + private function resolveCastMethod($cast) + { + $aliases = [ + 'integer' => 'castToInt', + 'float' => 'castToFloat', + 'double' => 'castToFloat', + 'json' => 'castToArray', + 'datetime' => 'castToDate', + ]; + + return isset($aliases[$cast]) ? $aliases[$cast] : 'castTo' . ucfirst($cast); } private function castToObject($value) @@ -581,6 +629,11 @@ private function castToInt($value) return (int) $value; } + private function castToFloat($value) + { + return (float) $value; + } + private function castToString($value) { return (string) $value; diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index b075542..b3a56c9 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -762,7 +762,11 @@ public function paginate($pageNo = 0, $perPage = 10) */ public function groupBy($columns) { - $columns = \is_array($columns) ? $columns : \func_get_args(); + $columns = \is_array($columns) ? $columns : \func_get_args(); + foreach ($columns as $column) { + $this->assertSafeIdentifier($column); + } + $this->groupBy = array_merge($this->groupBy, $columns); return $this; @@ -929,6 +933,8 @@ public function orOn($firstColumn, $operator = null, $secondColumn = null) */ public function orderBy($column) { + $this->assertSafeIdentifier($column); + $this->orderBy[] = [ 'column' => $column, 'direction' => 'ASC', @@ -1706,6 +1712,23 @@ private function nullOperator($operator) return \in_array($operator, $negations, true) ? 'IS NOT NULL' : 'IS NULL'; } + /** + * Guards an ORDER BY / GROUP BY column against injection: only a plain, + * qualified (table.column) or back-ticked identifier is accepted. Raw + * expressions must go through orderByRaw(). Valid identifiers are not + * re-rendered, so the emitted SQL stays byte-identical. + * + * @param mixed $column + * + * @return void + */ + private function assertSafeIdentifier($column) + { + if (!\is_string($column) || !preg_match('/^[A-Za-z0-9_.`]+$/', $column)) { + throw new RuntimeException('Unsafe column passed to order/group by clause.'); + } + } + /** * Returns true when the model declares soft-delete support. * @@ -1754,13 +1777,18 @@ private function bulkInsert($attributes) $sql .= ' VALUES '; $values = []; foreach ($attributes as $row) { - ksort($row); if ($createdAt) { $row['created_at'] = $this->currentTimestamp(); } - $rowValues = array_values($row); - $values[] = ' (' + // Align each row to the header columns by key so rows with differing + // keys are not positionally misaligned (absent column => NULL). + $rowValues = []; + foreach ($columns as $column) { + $rowValues[] = isset($row[$column]) ? $row[$column] : null; + } + + $values[] = ' (' . implode( ', ', array_map( @@ -1828,6 +1856,8 @@ private function prepareAttributeForSaveOrUpdate($isUpdate = false) $columnsToPrepare = array_keys($this->_model->getAttributes()); } + $columnsToPrepare = $this->withoutRelationColumns($columnsToPrepare); + if (property_exists($this->_model, 'timestamps') && $this->_model->timestamps) { if (!$isUpdate) { $this->_model->setAttribute('created_at', $this->currentTimestamp()); @@ -1856,6 +1886,25 @@ private function prepareAttributeForSaveOrUpdate($isUpdate = false) return $columnsToPrepare; } + /** + * Drops columns whose current value is a loaded relation (Collection/Model) + * from a save/update write set, so lazily-read relations are never persisted + * to a non-existent column. Re-indexes to keep bindings positionally aligned. + * + * @param array $columns + * + * @return array + */ + private function withoutRelationColumns(array $columns) + { + $attributes = $this->_model->getAttributes(); + + return array_values(array_filter($columns, function ($column) use ($attributes) { + return !\array_key_exists($column, $attributes) + || !$this->_model->isRelationValue($attributes[$column]); + })); + } + /** * Prepares insert statement * diff --git a/tests/CastAliasFixTest.php b/tests/CastAliasFixTest.php new file mode 100644 index 0000000..d609fba --- /dev/null +++ b/tests/CastAliasFixTest.php @@ -0,0 +1,60 @@ + '42']); + + $this->assertSame(42, $model->n); + } + + public function testFloatAliasCastsToFloat(): void + { + $model = new CastAliasModel(['f' => '4.5']); + + $this->assertSame(4.5, $model->f); + } + + public function testDoubleAliasCastsToFloat(): void + { + $model = new CastAliasModel(['d' => '2.5']); + + $this->assertSame(2.5, $model->d); + } + + public function testJsonAliasDecodesToArray(): void + { + $model = new CastAliasModel(['data' => '{"x":1}']); + + $this->assertSame(['x' => 1], $model->data); + } + + public function testDatetimeAliasCastsToDate(): void + { + $model = new CastAliasModel(['at' => '2024-01-02 03:04:05']); + + $this->assertInstanceOf(DateTime::class, $model->at); + } +} diff --git a/tests/Fixtures/CastAliasModel.php b/tests/Fixtures/CastAliasModel.php new file mode 100644 index 0000000..b709f92 --- /dev/null +++ b/tests/Fixtures/CastAliasModel.php @@ -0,0 +1,22 @@ + 'integer', + 'f' => 'float', + 'd' => 'double', + 'data' => 'json', + 'at' => 'datetime', + ]; +} diff --git a/tests/OrderGroupInjectionFixTest.php b/tests/OrderGroupInjectionFixTest.php new file mode 100644 index 0000000..9d34158 --- /dev/null +++ b/tests/OrderGroupInjectionFixTest.php @@ -0,0 +1,67 @@ +expectException(RuntimeException::class); + + User::query()->orderBy('id; DROP TABLE x'); + } + + public function testGroupByRejectsInjectionPayload(): void + { + $this->expectException(RuntimeException::class); + + User::query()->groupBy('a); DROP'); + } + + public function testOrderByPlainColumnUnchanged(): void + { + $sql = User::query()->orderBy('id', 'DESC')->toSql(); + + $this->assertStringContainsString('ORDER BY id ASC', $sql); + } + + public function testOrderByQualifiedColumnUnchanged(): void + { + $sql = User::query()->orderBy('t.col')->toSql(); + + $this->assertStringContainsString('ORDER BY t.col ASC', $sql); + } + + public function testGroupByPlainColumnUnchanged(): void + { + $sql = User::query()->groupBy('contact_id')->toSql(); + + $this->assertStringContainsString('GROUP BY contact_id', $sql); + } + + public function testGroupByQualifiedColumnUnchanged(): void + { + $sql = User::query()->groupBy('wp_x.module')->toSql(); + + $this->assertStringContainsString('GROUP BY wp_x.module', $sql); + } +} diff --git a/tests/RaggedBulkInsertFixTest.php b/tests/RaggedBulkInsertFixTest.php new file mode 100644 index 0000000..421f471 --- /dev/null +++ b/tests/RaggedBulkInsertFixTest.php @@ -0,0 +1,43 @@ +rows_affected = 0; + + User::query()->insert([ + ['a' => 'x', 'b' => 'y'], + ['b' => 'z', 'c' => 'w'], + ]); + + $sql = $GLOBALS['wpdb']->last_query; + + $this->assertStringContainsString('(a, b)', $sql); + $this->assertStringContainsString("('x', 'y')", $sql); + $this->assertStringContainsString("(NULL, 'z')", $sql); + $this->assertStringNotContainsString("('z'", $sql); + } +} diff --git a/tests/RelationDirtyFixTest.php b/tests/RelationDirtyFixTest.php new file mode 100644 index 0000000..b34dbfe --- /dev/null +++ b/tests/RelationDirtyFixTest.php @@ -0,0 +1,74 @@ +resolver = static function ($sql) { + if (strpos($sql, 'wp_posts') !== false) { + return [(object) ['id' => 5, 'user_id' => 1, 'title' => 'p']]; + } + + return [(object) ['id' => 1, 'name' => 'Ada']]; + }; + + $user = User::query()->where('id', 1)->first(); + $posts = $user->posts; + + $this->assertInstanceOf(Collection::class, $posts); + $this->assertArrayNotHasKey('posts', $user->getDirtyAttributes()); + + $user->name = 'Grace'; + + $GLOBALS['wpdb']->queries = []; + $GLOBALS['wpdb']->last_query = ''; + + $user->save(); + + $sql = $GLOBALS['wpdb']->last_query; + $this->assertStringContainsString('UPDATE wp_users', $sql); + $this->assertStringContainsString('name =', $sql); + $this->assertStringNotContainsString('posts', $sql); + } + + public function testRealArrayColumnIsStillWritten(): void + { + $GLOBALS['wpdb']->resolver = static function () { + return [(object) ['id' => 1, 'name' => 'Ada']]; + }; + + $user = User::query()->where('id', 1)->first(); + $user->tags = ['a', 'b']; + + $GLOBALS['wpdb']->queries = []; + $GLOBALS['wpdb']->last_query = ''; + + $user->save(); + + $sql = $GLOBALS['wpdb']->last_query; + $this->assertStringContainsString('tags =', $sql); + $this->assertStringContainsString('["a","b"]', $sql); + } +} diff --git a/tests/SchemaDdlFixTest.php b/tests/SchemaDdlFixTest.php new file mode 100644 index 0000000..711f0f3 --- /dev/null +++ b/tests/SchemaDdlFixTest.php @@ -0,0 +1,55 @@ +string('email')->unique(); + }); + + $this->assertStringContainsString('ADD UNIQUE INDEX', $GLOBALS['wpdb']->last_query); + } + + public function testEditRenameColumnIsEmitted(): void + { + Schema::edit('orders', function ($table) { + $table->renameColumn('old_name', 'new_name'); + }); + + $this->assertStringContainsString( + 'RENAME COLUMN old_name TO new_name', + $GLOBALS['wpdb']->last_query + ); + } + + public function testDecimalEmitsPrecisionAndScale(): void + { + Schema::create('invoices', function ($table) { + $table->decimal('amount', 8, 2); + }); + + $this->assertStringContainsString('DECIMAL(8, 2)', $GLOBALS['wpdb']->last_query); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 8fb7b06..b012e3e 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -129,6 +129,7 @@ public function has_cap($cap) require __DIR__ . '/Fixtures/EventUser.php'; require __DIR__ . '/Fixtures/RetrieveUser.php'; require __DIR__ . '/Fixtures/CastModel.php'; +require __DIR__ . '/Fixtures/CastAliasModel.php'; require __DIR__ . '/Fixtures/AccessorModel.php'; require __DIR__ . '/Fixtures/CreatingUser.php'; require __DIR__ . '/Fixtures/ScopedSoftPost.php'; From c710ad00b60d42ce31ac856eaac9665e938c6869 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Wed, 1 Jul 2026 00:19:05 +0600 Subject: [PATCH 082/102] docs: Phase 2 behavioral notes (order/group validation, cast aliases, ragged bulk) Assisted-By: AI --- docs/breaking-changes.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/breaking-changes.md b/docs/breaking-changes.md index 2c9a9de..f3002f3 100644 --- a/docs/breaking-changes.md +++ b/docs/breaking-changes.md @@ -335,6 +335,15 @@ Not signature breaks, but observable runtime differences. `insert()` row whose first value is an array no longer crashes. - **`take()` / `skip()` cast their argument to `int`** — blocks `LIMIT`/`OFFSET` injection; numeric input is byte-identical. +- **`orderBy()` / `groupBy()` validate the column** as a plain identifier + (`^[A-Za-z0-9_.`]+$`) and throw `RuntimeException` otherwise — blocks + `ORDER BY`/`GROUP BY` injection. Plain/qualified identifiers emit byte-identical + SQL; pass raw expressions through `orderByRaw()`. +- **Cast aliases `integer`/`float`/`double`/`json`/`datetime` now work** (map onto + the existing casters) — they were previously silent no-ops returning the raw value. +- **Bulk `insert()` aligns ragged rows by column** — a row whose keys differ from + the first row no longer silently shifts values into the wrong columns (uniform + rows unchanged). - **`join()` table prefix corrected for custom-`$prefix` models.** Join (and pivot) tables now carry the model's **full** table prefix via the new `Model::getTablePrefix()` (`wp_` plus the plugin prefix), matching the model's From 65fff45ba22795acaf3ce52572166153d237af5a Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Wed, 1 Jul 2026 00:41:50 +0600 Subject: [PATCH 083/102] fix: eager empty relation resolves to [] without an N+1 re-query (C3) An eager-loaded parent with no related rows stored null, so accessing the relation fell through offsetExists() to a fresh lazy query (the N+1 the eager load exists to prevent) that returned an empty Collection. Store [] instead: offsetExists() is true, the resolved-empty value is returned with no extra query. Empty either way (count 0, falsy); the type now matches a non-empty eager relation (plain array). No existing test relied on the null. Assisted-By: AI --- docs/breaking-changes.md | 6 ++++++ src/Concerns/Relations.php | 6 ++++-- tests/EagerLoadIntegrationTest.php | 30 ++++++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/docs/breaking-changes.md b/docs/breaking-changes.md index f3002f3..a1852e3 100644 --- a/docs/breaking-changes.md +++ b/docs/breaking-changes.md @@ -344,6 +344,12 @@ Not signature breaks, but observable runtime differences. - **Bulk `insert()` aligns ragged rows by column** — a row whose keys differ from the first row no longer silently shifts values into the wrong columns (uniform rows unchanged). +- **Eager-loaded empty relations resolve to `[]` without a re-query.** A parent + with no related rows previously stored `null`, so accessing the relation fired a + fresh lazy query (an N+1) that returned an empty `Collection`. It now holds `[]` + directly — no extra query. The value is empty either way (`count()` 0, falsy); + the type for an empty eager relation is now a plain array, matching a non-empty + eager relation. - **`join()` table prefix corrected for custom-`$prefix` models.** Join (and pivot) tables now carry the model's **full** table prefix via the new `Model::getTablePrefix()` (`wp_` plus the plugin prefix), matching the model's diff --git a/src/Concerns/Relations.php b/src/Concerns/Relations.php index 71d2533..f32e6aa 100644 --- a/src/Concerns/Relations.php +++ b/src/Concerns/Relations.php @@ -342,9 +342,11 @@ private function setRelatedData(Model $model) $relationKey = $relationQuery->getModel()->getActiveRelationKey(); + // empty relations store [] (not null) so accessing them returns the + // resolved-empty result instead of falling through to a lazy re-query (N+1). $data = isset( $this->_relatedData[$relationName][$model->getAttribute($relationKey['localKey'])] - ) ? $this->_relatedData[$relationName][$model->getAttribute($relationKey['localKey'])] : null; + ) ? $this->_relatedData[$relationName][$model->getAttribute($relationKey['localKey'])] : []; if ($relationQuery->getModel()->getRelateAs() === 'oneToOne' && is_countable($data) && \count($data)) { $data = $data[0]; @@ -363,7 +365,7 @@ private function setPivotRelatedData(Model $model, $relationName, Model $related $key = $model->getAttribute($pivot['parentKey']); $data = isset($this->_relatedData[$relationName][$key]) ? $this->_relatedData[$relationName][$key] - : null; + : []; [$name, $alias] = $this->prepareRelationName($relationName); $model->setAttribute(\is_null($alias) ? $name : $alias, $data); diff --git a/tests/EagerLoadIntegrationTest.php b/tests/EagerLoadIntegrationTest.php index 24f7bf2..ac99069 100644 --- a/tests/EagerLoadIntegrationTest.php +++ b/tests/EagerLoadIntegrationTest.php @@ -53,6 +53,36 @@ public function testStaticWithEagerLoadsAndGroupsRelatedRows(): void $this->assertInstanceOf(Post::class, $first->posts[0]); } + /** + * A parent with NO related rows must not trigger a fresh lazy query when the + * relation is accessed (the eager load already resolved it to empty) — the + * N+1 the eager load exists to prevent. + */ + public function testEmptyEagerRelationDoesNotReQueryOnAccess(): void + { + $GLOBALS['wpdb']->resolver = static function ($sql) { + if (strpos($sql, 'wp_posts') !== false) { + return [(object) ['id' => 10, 'user_id' => 1]]; + } + + return [(object) ['id' => 1], (object) ['id' => 2]]; + }; + + $users = User::with('posts')->get(); + $second = null; + foreach ($users as $u) { + if ((int) $u->id === 2) { + $second = $u; + } + } + + $GLOBALS['wpdb']->queries = []; + $posts = $second->posts; // user 2 has no posts + + $this->assertCount(0, $posts, 'empty eager relation resolves to empty'); + $this->assertCount(0, $GLOBALS['wpdb']->queries, 'no re-query (N+1) on accessing an empty eager relation'); + } + /** * The parent's selectRaw must NOT leak into the eager key subquery — that * subquery feeds an IN (...) and must return exactly one column (the localKey), From ede27bbe168fc8493ff4e9aa7c70eeb922074fc0 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Wed, 1 Jul 2026 11:26:14 +0600 Subject: [PATCH 084/102] fix: soft-delete reads exclude trashed rows by default A model with $soft_deletes = true now injects deleted_at IS NULL on every SELECT automatically; trashed rows no longer appear. Opt out with public $soft_delete_scope = false to restore the unfiltered read. refresh() reloads a row by its own primary key with withTrashed(), so a re-hydrated trashed model still reports exists() === true and the next save() UPDATEs instead of re-INSERTing a duplicate. Scope is injected on SELECT only; delete/restore/forceDelete/insert/ upsert/aggregate paths are unaffected. Assisted-By: AI --- src/Model.php | 5 ++++- src/QueryBuilder.php | 11 +++++++++-- tests/Fixtures/UnscopedSoftPost.php | 18 ++++++++++++++++++ tests/SoftDeleteScopeTest.php | 13 +++++++++++-- tests/bootstrap.php | 1 + 5 files changed, 43 insertions(+), 5 deletions(-) create mode 100644 tests/Fixtures/UnscopedSoftPost.php diff --git a/src/Model.php b/src/Model.php index 961df86..897c1d4 100644 --- a/src/Model.php +++ b/src/Model.php @@ -238,7 +238,10 @@ public function refresh() return false; } - $result = $this->newQuery()->findOne([$this->primaryKey => $this->attributes[$this->primaryKey]]); + // withTrashed(): refresh reloads this row by its own PK, so it must find + // the row even when trashed — otherwise a hydrated soft-deleted model + // reports exists() === false and the next save() re-INSERTs a duplicate. + $result = $this->newQuery()->withTrashed()->findOne([$this->primaryKey => $this->attributes[$this->primaryKey]]); if (!$result) { $this->_isExists = false; diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index b3a56c9..8e0edad 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -1740,13 +1740,20 @@ private function isSoftDeleteModel() } /** - * Returns true when the model opts into automatic soft-delete read scope. + * Returns true when soft-delete read scope is active for the model. + * + * Soft-delete models exclude trashed rows by default; a model opts out by + * declaring public $soft_delete_scope = false. * * @return bool */ private function autoScopeEnabled() { - return property_exists($this->_model, 'soft_delete_scope') && $this->_model->soft_delete_scope; + if (property_exists($this->_model, 'soft_delete_scope')) { + return (bool) $this->_model->soft_delete_scope; + } + + return true; } /** diff --git a/tests/Fixtures/UnscopedSoftPost.php b/tests/Fixtures/UnscopedSoftPost.php new file mode 100644 index 0000000..9eb8140 --- /dev/null +++ b/tests/Fixtures/UnscopedSoftPost.php @@ -0,0 +1,18 @@ +toSql(); + $this->assertStringContainsString('deleted_at', $sql); + $this->assertStringContainsString('IS NULL', $sql); + } + + // Opt-out: $soft_delete_scope = false restores the unfiltered read + public function testSoftDeleteScopeFalseOptsOutOfFilter(): void + { + $sql = UnscopedSoftPost::query()->toSql(); $this->assertStringNotContainsString('deleted_at', $sql); } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index b012e3e..e5c5697 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -133,6 +133,7 @@ public function has_cap($cap) require __DIR__ . '/Fixtures/AccessorModel.php'; require __DIR__ . '/Fixtures/CreatingUser.php'; require __DIR__ . '/Fixtures/ScopedSoftPost.php'; +require __DIR__ . '/Fixtures/UnscopedSoftPost.php'; require __DIR__ . '/Fixtures/TimestampedRow.php'; require __DIR__ . '/Fixtures/Role.php'; require __DIR__ . '/Fixtures/Member.php'; From 02ab5582262c0d7e120a8db7669fed6d9fa27d6c Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Wed, 1 Jul 2026 11:26:23 +0600 Subject: [PATCH 085/102] docs: add relations reference; soft-delete default-filter notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add docs/relations.md — a standalone relationship reference covering hasOne/belongsTo (the shared oneToOne alias and reversed-from-Laravel key naming), hasMany, belongsToMany pivot relations (signature, key defaults, withPivot, pivot_* attributes, read-only limits), eager/lazy loading, and relation aggregates. Linked from README and usage.md; the deep belongsToMany block and relation Limitations bullets in usage.md now point to it. Document the soft-delete default flip: usage.md (property table, deleting section, limitations) and breaking-changes.md (new 2.13 + summary row 13). Assisted-By: AI --- README.md | 2 + docs/breaking-changes.md | 30 +++++ docs/relations.md | 268 +++++++++++++++++++++++++++++++++++++++ docs/usage.md | 130 +++++-------------- 4 files changed, 333 insertions(+), 97 deletions(-) create mode 100644 docs/relations.md diff --git a/README.md b/README.md index c2e3f93..185f946 100644 --- a/README.md +++ b/README.md @@ -43,5 +43,7 @@ $active = Contact::where('is_active', 1) - **[Usage guide](docs/usage.md)** — models, query builder, relationships, casts, events, transactions, and more. +- **[Relationships](docs/relations.md)** — `hasOne`/`belongsTo`/`hasMany`/`belongsToMany`, + eager & lazy loading, relation aggregates, and limitations. - **[Schema builder](docs/schema.md)** — table creation, columns, indexes, and migrations. - **[Breaking changes](docs/breaking-changes.md)** — upgrade notes. diff --git a/docs/breaking-changes.md b/docs/breaking-changes.md index a1852e3..1cbc66c 100644 --- a/docs/breaking-changes.md +++ b/docs/breaking-changes.md @@ -29,6 +29,7 @@ API and runtime behavior change, it is a **major** version bump. | 10 | Relations | `addRelation()` signature `string` → `array`, void | Medium | | 11 | Blueprint | `binary()` removed | Low | | 12 | QueryBuilder | exception type/message changed in `exec()` | Low | +| 13 | Model | soft-delete reads exclude trashed by default (opt out with `$soft_delete_scope = false`) | Medium | --- @@ -285,6 +286,35 @@ specifically for this case, should be reviewed. --- +### 2.13 Soft-delete reads now exclude trashed rows by default + +A model with `public $soft_deletes = true;` previously returned **all** rows — +trashed and non-trashed alike — unless it also opted in with +`$soft_delete_scope = true`. Reads now inject `deleted_at IS NULL` **by default**, +so trashed rows no longer appear. + +```php +class Post extends Model +{ + public $soft_deletes = true; // reads now hide trashed rows automatically +} + +Post::all(); // excludes trashed rows +Post::withTrashed()->get(); // include trashed +Post::onlyTrashed()->get(); // only trashed +``` + +**Migration:** to keep the old unfiltered behavior, declare the opt-out flag: + +```php +public $soft_delete_scope = false; // reads return every row, including trashed +``` + +`refresh()` reloads a row by its own primary key with `withTrashed()`, so +re-hydrating a trashed model still reports `exists() === true`. + +--- + ## 3. Behavioral changes Not signature breaks, but observable runtime differences. diff --git a/docs/relations.md b/docs/relations.md new file mode 100644 index 0000000..4fa2a73 --- /dev/null +++ b/docs/relations.md @@ -0,0 +1,268 @@ +# wp-database — Relationships + +Full reference for defining and loading relationships. For the broader API see +the [Usage guide](usage.md). + +Relationships are declared as **methods** on a model; each method returns a query +for the related model. Load them eagerly with `with()` or lazily by accessing the +method name as a property (`$model->relation`). + +- [Key convention](#key-convention) +- [`hasOne` / `belongsTo` (one-to-one)](#hasone--belongsto-one-to-one) +- [`hasMany` (one-to-many)](#hasmany-one-to-many) +- [`belongsToMany` (many-to-many)](#belongstomany-many-to-many) +- [Eager loading](#eager-loading) +- [Lazy loading](#lazy-loading) +- [Relation aggregates & existence](#relation-aggregates--existence) +- [Limitations](#limitations) + +--- + +## Key convention + +> **`$foreignKey` is the column on the _related_ model's table; `$localKey` is +> the column on the _calling_ model's table.** This is the **reverse** of +> Laravel's naming — always pass both arguments explicitly when your columns +> differ from the ORM default (`{callerTable}_id` / `id`) to avoid confusion. + +Every relation method takes the same first three arguments: + +```php +relation($model, $foreignKey = null, $localKey = null) +``` + +Omitted keys derive from the package's foreign-key convention: `$foreignKey` +defaults to the caller's `getForeignKey()` (`{tableWithoutPrefix}_{primaryKey}`, +e.g. `contacts_id` — note: plural, unlike Laravel's singular default) and +`$localKey` defaults to the caller's primary key. + +--- + +## `hasOne` / `belongsTo` (one-to-one) + +**`hasOne()` is a direct alias of `belongsTo()`.** Both set the same `oneToOne` +relation type and return a single related model. There is no separate +reverse-direction implementation — direction is determined entirely by the keys +you pass and which model calls the method. + +```php +class Contact extends Model +{ + public function profile() + { + // foreignKey='contact_id' on the profiles (related) table + // localKey='id' on the contacts (this) table + // Predicate: WHERE profiles.contact_id IN (SELECT id FROM contacts) + return $this->hasOne(Profile::class, 'contact_id', 'id'); + } +} + +class Deal extends Model +{ + public function contact() + { + // Deal.contact_id references Contact.id + // foreignKey='id' on the contacts (related) table + // localKey='contact_id' on the deals (this) table + // Predicate: WHERE contacts.id IN (SELECT contact_id FROM deals) + return $this->belongsTo(Contact::class, 'id', 'contact_id'); + } +} +``` + +A `oneToOne` relation resolves to a single `Model` (or `[]` when there is no +match), not a `Collection`. + +--- + +## `hasMany` (one-to-many) + +Same key convention; resolves to a `Collection` of related models. + +```php +class Contact extends Model +{ + public function deals() + { + // foreignKey='contact_id' lives on the deals table + // localKey='id' lives on the contacts table + // Predicate: WHERE deals.contact_id IN (SELECT id FROM contacts) + return $this->hasMany(Deal::class, 'contact_id', 'id'); + } +} +``` + +--- + +## `belongsToMany` (many-to-many) + +Resolves a many-to-many relation through a pivot (junction) table. Signature: + +```php +belongsToMany( + $model, + $pivotTable = null, // unprefixed pivot/junction table name + $foreignPivotKey = null, // parent's key column ON the pivot table + $relatedPivotKey = null, // related's key column ON the pivot table + $parentKey = null, // local key column on the parent table + $relatedKey = null // key column on the related table +) +``` + +When `$pivotTable` is `null` the method keeps its **legacy** behaviour (resolves +exactly like `hasMany` — the related table must carry the parent FK). Pass the +pivot table name (unprefixed; the package prefixes it like `join()` does) to get +real pivot behaviour. + +Omitted keys derive from the package's foreign-key convention: + +| Argument | Default | Member (`members`) ↔ Role (`roles`) | +|---|---|---| +| `$foreignPivotKey` | parent `getForeignKey()` | `members_id` | +| `$relatedPivotKey` | related `getForeignKey()` | `roles_id` | +| `$parentKey` | parent `getPrimaryKey()` | `id` | +| `$relatedKey` | related `getPrimaryKey()` | `id` | + +```php +class Member extends Model +{ + protected $table = 'members'; + + public function roles() + { + // pivot table role_user(member_id, role_id) + return $this->belongsToMany(Role::class, 'role_user', 'member_id', 'role_id'); + } + + // Carry extra pivot columns; they surface flat as `pivot_` attributes + public function rolesWithAssignment() + { + return $this->belongsToMany(Role::class, 'role_user', 'member_id', 'role_id') + ->withPivot(['assigned_at']); + } +} +``` + +The link column rides along on every related model as the reserved attribute +`pivot_` (e.g. `pivot_member_id`), and each `withPivot()` column +as `pivot_` (e.g. `pivot_assigned_at`). These flat `pivot_*` attributes +appear in `toArray()`. + +```php +// Eager +foreach (Member::with('roles')->get() as $member) { + foreach ($member->roles as $role) { + echo $role->pivot_member_id; // the parent link + } +} + +// Lazy +$member = Member::query()->findOne(['id' => 1]); +foreach ($member->roles as $role) { /* ... */ } +``` + +Pivot relations are **read-only**: `attach`/`detach`/`sync` and +`withCount`/`whereHas`/aggregates over a pivot relation are **not** supported +(the aggregates throw a `RuntimeException`). See [Limitations](#limitations). + +--- + +## Eager loading + +Load a relation for every parent in one extra query (avoids the N+1 problem). + +```php +Contact::with('deals')->get(); // load deals for each contact +Contact::with(['deals', 'profile'])->get(); // multiple relations + +// Constrain the eager-loaded relation +Contact::with('deals', function ($q) { + $q->where('status', 'open'); +})->get(); + +// Alias the loaded relation key on the result model +Contact::with('deals as open_deals')->get(); + +// Access loaded relations +foreach (Contact::with('deals')->get() as $contact) { + foreach ($contact->deals as $deal) { /* ... */ } +} +``` + +A parent with no related rows resolves to an empty result **without** triggering +a fresh lazy query when the relation is later accessed — the eager load already +resolved it to empty. + +--- + +## Lazy loading + +Access the relation method name as a property to load it on demand: + +```php +$contact = Contact::query()->findOne(['id' => 1]); + +$contact->deals; // Collection, queried on first access +$contact->profile; // single Model or [] +``` + +--- + +## Relation aggregates & existence + +```php +Contact::withCount('deals')->get(); // adds `deals_count` +Contact::withSum('deals.amount')->get(); // adds `deals_sum` +Contact::withAvg('deals.amount')->get(); // adds `deals_avg` +Contact::withMin('deals.amount')->get(); // adds `deals_min` +Contact::withMax('deals.amount')->get(); // adds `deals_max` +Contact::withExists('deals')->get(); // adds bool-cast `deals_exists` + +// Filter by relation existence +Contact::whereHas('deals')->get(); +Contact::whereHas('deals', fn ($q) => $q->where('status', 'open'))->get(); + +// Filter by existence AND eager-load the same relation +Contact::withWhereHas('deals', fn ($q) => $q->where('status', 'open'))->get(); +``` + +Aggregate columns are aliased `_` by default +(e.g. `deals_count`, `deals_sum`). Pass `'relation as alias'` for a custom name: + +```php +Contact::withCount('deals as total_deals')->get(); // adds `total_deals` +Contact::withSum('deals.amount as revenue')->get(); // adds `revenue` +``` + +For `withMin`, `withMax`, `withAvg`, and `withSum` the column to aggregate must +be given as `'relation.column'`; passing just the relation name defaults to `*`, +which is meaningful only for `withCount` and `withExists`. + +--- + +## Limitations + +- **`belongsTo` and `hasOne` are the same alias; key naming is reversed from + Laravel.** Both set the `oneToOne` relation. `$foreignKey` is the column on the + **related** table and `$localKey` is the column on the **calling** model's + table — the opposite of Laravel. Supply both arguments explicitly. + +- **`belongsToMany` pivot relations are read-only and single-key.** Real + pivot-table many-to-many is supported for reads (eager `with()` + lazy + `$model->relation`), with these gaps: + - No `withCount`/`whereHas`/aggregates on a pivot relation — they **throw** + `RuntimeException` (the pivot metadata has no single `foreignKey`/`localKey`). + - No `attach`/`detach`/`sync` (write side is out of scope). + - Single-column pivot/parent/related keys only — no composite keys. + - Duplicate pivot rows yield duplicate related models (no `DISTINCT`). + - Eager constraint closures may add `where`/`orderBy`/`limit` but **cannot** + narrow the selected columns — the pivot path always selects `related.*` so + the aliased pivot column can ride along. + - Pivot values surface as flat **reserved** `pivot_*` attributes (including the + link key `pivot_`) and appear in `toArray()`. A related + column literally named `pivot_*` would be overwritten. These attributes are + excluded from dirty-tracking on UPDATE, so re-saving a hydrated related model + is safe; a forced re-INSERT would attempt to write the non-existent columns. + - Null parent key: the eager path buckets a null parent key under null + (relation resolves to `null`); the lazy path renders `… IS NULL` and returns + pivot rows whose link column is NULL — a minor divergence. diff --git a/docs/usage.md b/docs/usage.md index bf77c85..d3255ca 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -110,7 +110,7 @@ class Contact extends Model ]; // Soft deletes: delete() sets deleted_at instead of removing the row. - // NOTE: reads are NOT filtered — soft-deleted rows appear in all() and every query. + // Reads exclude trashed rows by default; opt out with $soft_delete_scope = false. public $soft_deletes = true; } ``` @@ -123,7 +123,7 @@ class Contact extends Model | `$fillable` | Mass-assignment allow-list. Unset = allow all non-timestamp, non-PK attributes. | | `$casts` | Map of column → cast type. See [Attribute casting](#attribute-casting). | | `$timestamps` | Auto-set `created_at`/`updated_at` on insert/update (declared `true` in the base `Model`; set to `false` to disable). Requires the columns to exist — see [`timestamps()` in `docs/schema.md`](schema.md#timestamps). | -| `$soft_deletes` | Must be declared `true` on the model. `delete()` then sets `deleted_at` instead of removing the row. Reads return **all rows by default** — including trashed ones. To enable automatic filtering, also declare `public $soft_delete_scope = true;`; reads will then exclude trashed rows automatically. Use `->withTrashed()` to include them, or `->onlyTrashed()` to return only trashed rows. See [Limitations](#limitations--known-issues) and [`softDeletes()` in `docs/schema.md`](schema.md#softdeletes). | +| `$soft_deletes` | Must be declared `true` on the model. `delete()` then sets `deleted_at` instead of removing the row. Reads **exclude trashed rows by default**. Use `->withTrashed()` to include them, or `->onlyTrashed()` to return only trashed rows. Declare `public $soft_delete_scope = false;` to opt out of the filter (reads return every row, including trashed). See [Limitations](#limitations--known-issues) and [`softDeletes()` in `docs/schema.md`](schema.md#softdeletes). | --- @@ -371,10 +371,11 @@ Contact::destroy([1, 2, 3]); // delete by primary keys `RuntimeException('SQL query is empty')` — a guard against wiping the table. Use a raw `TRUNCATE` if you really mean to empty it. - With `$soft_deletes = true`, `delete()` sets `deleted_at` instead of removing - the row. Reads return **all rows by default** — including trashed ones. Add - `public $soft_delete_scope = true;` to enable automatic filtering: reads exclude - trashed rows, `->withTrashed()` includes them, `->onlyTrashed()` returns only - trashed rows. See [Limitations](#limitations--known-issues). + the row, and reads **exclude trashed rows by default**. Use `->withTrashed()` + to include them, or `->onlyTrashed()` to return only trashed rows. To opt out + of the automatic filter (reads return every row, including trashed), declare + `public $soft_delete_scope = false;`. See + [Limitations](#limitations--known-issues). - On a soft-delete model, `forceDelete()` emits a real `DELETE` (bypassing the soft rewrite) and `restore()` clears `deleted_at`. Both throw on a model without `$soft_deletes`. @@ -445,6 +446,10 @@ Contact::query()->withCast(['is_active' => 'bool'])->get(); ## Relationships +> **Full reference:** [docs/relations.md](relations.md) — every relation type, +> eager/lazy loading, aggregates, and relation-specific limitations in one place. +> This section is a quick overview. + Define relationships as methods on the model. The relation method returns a query for the related model. @@ -495,35 +500,8 @@ Available: `hasOne()`, `hasMany()`, `belongsTo()` — each takes ### Many-to-many (`belongsToMany`) -`belongsToMany` resolves a many-to-many relation through a pivot (junction) -table. Signature: - -```php -belongsToMany( - $model, - $pivotTable = null, // unprefixed pivot/junction table name - $foreignPivotKey = null, // parent's key column ON the pivot table - $relatedPivotKey = null, // related's key column ON the pivot table - $parentKey = null, // local key column on the parent table - $relatedKey = null // key column on the related table -) -``` - -When `$pivotTable` is `null` the method keeps its **legacy** behaviour (resolves -exactly like `hasMany` — the related table must carry the parent FK). Pass the -pivot table name (unprefixed; the package prefixes it like `join()` does) to get -real pivot behaviour. - -Omitted keys derive from the package's own foreign-key convention -(`{tableWithoutPrefix}_{primaryKey}`, e.g. `members_id` — note: plural, unlike -Laravel's singular default): - -| Argument | Default | Member (`members`) ↔ Role (`roles`) | -|---|---|---| -| `$foreignPivotKey` | parent `getForeignKey()` | `members_id` | -| `$relatedPivotKey` | related `getForeignKey()` | `roles_id` | -| `$parentKey` | parent `getPrimaryKey()` | `id` | -| `$relatedKey` | related `getPrimaryKey()` | `id` | +Resolves a many-to-many relation through a pivot (junction) table. Pass the +unprefixed pivot table name plus the parent/related key columns on it: ```php class Member extends Model @@ -535,37 +513,16 @@ class Member extends Model // pivot table role_user(member_id, role_id) return $this->belongsToMany(Role::class, 'role_user', 'member_id', 'role_id'); } - - // Carry extra pivot columns; they surface flat as `pivot_` attributes - public function rolesWithAssignment() - { - return $this->belongsToMany(Role::class, 'role_user', 'member_id', 'role_id') - ->withPivot(['assigned_at']); - } } ``` -The link column rides along on every related model as the reserved attribute -`pivot_` (e.g. `pivot_member_id`), and each `withPivot()` column -as `pivot_` (e.g. `pivot_assigned_at`). These flat `pivot_*` attributes -appear in `toArray()`. - -```php -// Eager -foreach (Member::with('roles')->get() as $member) { - foreach ($member->roles as $role) { - echo $role->pivot_member_id; // the parent link - } -} - -// Lazy -$member = Member::query()->findOne(['id' => 1]); -foreach ($member->roles as $role) { /* ... */ } -``` +With no `$pivotTable` the call keeps its **legacy** `hasMany`-style behaviour. +Pivot values surface as flat reserved `pivot_*` attributes; add extra ones with +`->withPivot([...])`. Pivot relations are **read-only** — `attach`/`detach`/`sync` +and aggregates over a pivot relation are not supported (the latter throw). -Read-only only: `attach`/`detach`/`sync` and `withCount`/`whereHas`/aggregates -over a pivot relation are **not** supported (the latter throw). See -[Limitations](#limitations--known-issues). +See [docs/relations.md](relations.md#belongstomany-many-to-many) for the full +signature, key-defaults table, `withPivot`, eager/lazy access, and limitations. ### Eager loading @@ -750,41 +707,20 @@ method relocation. ## Limitations & known issues -- **`belongsToMany` pivot relations are read-only and single-key.** Real - pivot-table many-to-many is supported for reads (eager `with()` + lazy - `$model->relation`), but with these gaps: - - No `withCount`/`whereHas`/aggregates on a pivot relation — they **throw** - `RuntimeException` (the pivot metadata has no single `foreignKey`/`localKey`). - - No `attach`/`detach`/`sync` (write side is out of scope). - - Single-column pivot/parent/related keys only — no composite keys. - - Duplicate pivot rows yield duplicate related models (no `DISTINCT`). - - Eager constraint closures may add `where`/`orderBy`/`limit` but **cannot** - narrow the selected columns — the pivot path always selects `related.*` so - the aliased pivot column can ride along. - - Pivot values surface as flat **reserved** `pivot_*` attributes (including the - link key `pivot_`) and appear in `toArray()`. A related - column literally named `pivot_*` would be overwritten. These attributes are - excluded from dirty-tracking on UPDATE, so re-saving a hydrated related model - is safe; a forced re-INSERT would attempt to write the non-existent columns. - - Null parent key: the eager path buckets a null parent key under null - (relation resolves to `null`); the lazy path renders `… IS NULL` and returns - pivot rows whose link column is NULL — a minor divergence. - -- **`belongsTo` and `hasOne` are the same alias; key naming is reversed from - Laravel.** Both set the same `oneToOne` relation. The `$foreignKey` argument - is the column on the **related** table and `$localKey` is the column on the - **calling** model's table — the opposite of Laravel's convention. Ensure you - supply both arguments explicitly to avoid confusion. - -- **Soft delete reads are unfiltered by default.** `softDeletes()` adds a - `deleted_at` column and `delete()` sets it. Reads return all rows — including - trashed ones — unless you also declare `public $soft_delete_scope = true;` on - the model. With the flag set, reads automatically exclude trashed rows; - `->withTrashed()` includes them and `->onlyTrashed()` returns only trashed rows. - **Edge case:** `refresh()` / `exists()` use a default (now scoped) read, so a - trashed opt-in model reloaded without `->withTrashed()` reports `exists() === false` - and a subsequent `save()` will INSERT rather than UPDATE. Follow-up planned to - thread soft-delete awareness into `refresh()`. +- **Relation limitations** — `belongsToMany` pivot relations are read-only and + single-key (no `attach`/`detach`/`sync`, no aggregates over a pivot relation), + and `belongsTo`/`hasOne` share one `oneToOne` alias whose key naming is + **reversed from Laravel**. Full detail: + [docs/relations.md](relations.md#limitations). + +- **Soft delete reads exclude trashed rows by default.** `softDeletes()` adds a + `deleted_at` column and `delete()` sets it. Reads automatically filter out + trashed rows (`deleted_at IS NULL`); `->withTrashed()` includes them and + `->onlyTrashed()` returns only trashed rows. Declare + `public $soft_delete_scope = false;` on the model to opt out of the filter and + read every row, including trashed ones. `refresh()` reloads a row by its own + primary key with `withTrashed()`, so re-hydrating a trashed model still reports + `exists() === true` and a following `save()` correctly UPDATEs. - **`upsert` is MySQL-only.** It generates `INSERT … ON DUPLICATE KEY UPDATE`, which is not portable to other databases. Workaround: use separate `insert` + From 6b61445518b38d0a5080e9e93d58859ee985eabc Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Wed, 1 Jul 2026 11:26:36 +0600 Subject: [PATCH 086/102] test: add RED relation-resolution safety tests (impl pending) Failing tests pinning the desired behavior for relation-name resolution: with()/withCount()/whereHas() must reject a non-relation method name with a RuntimeException, and getActiveRelationKey() must fail loudly on an unknown relation tag rather than returning a silent null. These are the RED phase for the not-yet-implemented relation-safety fix (option alpha) and fail until that guard lands. Assisted-By: AI --- tests/Fixtures/RelationSentinel.php | 28 +++++++ tests/RelationResolutionSafetyTest.php | 101 +++++++++++++++++++++++++ tests/bootstrap.php | 1 + 3 files changed, 130 insertions(+) create mode 100644 tests/Fixtures/RelationSentinel.php create mode 100644 tests/RelationResolutionSafetyTest.php diff --git a/tests/Fixtures/RelationSentinel.php b/tests/Fixtures/RelationSentinel.php new file mode 100644 index 0000000..ad0a253 --- /dev/null +++ b/tests/Fixtures/RelationSentinel.php @@ -0,0 +1,28 @@ +hasMany(Post::class, 'sentinel_id', 'id'); + } + + /** NOT a relation — must be rejected, never used as one. */ + public function destroyTheWorld() + { + return 'not a query builder'; + } +} diff --git a/tests/RelationResolutionSafetyTest.php b/tests/RelationResolutionSafetyTest.php new file mode 100644 index 0000000..3a608e8 --- /dev/null +++ b/tests/RelationResolutionSafetyTest.php @@ -0,0 +1,101 @@ +assertInstanceOf( + RuntimeException::class, + $caught, + 'a non-relation method name must be rejected at resolution with a RuntimeException' + ); + } + + public function testWithRejectsNonRelationMethod(): void + { + // must be rejected at resolution (the with() call), not blow up later. + $this->assertRejectedAsRelation(static function () { + RelationSentinel::with('destroyTheWorld'); + }); + } + + public function testWithCountRejectsNonRelationMethod(): void + { + $this->assertRejectedAsRelation(static function () { + RelationSentinel::withCount('destroyTheWorld'); + }); + } + + public function testWhereHasRejectsNonRelationMethod(): void + { + $this->assertRejectedAsRelation(static function () { + RelationSentinel::whereHas('destroyTheWorld'); + }); + } + + public function testGenuineRelationStillResolves(): void + { + // zero-BC guard: a real relation method must keep resolving, never rejected. + try { + RelationSentinel::with('posts')->get(); + } catch (RuntimeException $e) { + $this->fail('a genuine relation must not be rejected: ' . $e->getMessage()); + } + + $this->addToAssertionCount(1); + } + + public function testGetActiveRelationKeyFailsLoudlyOnUnknownTag(): void + { + $model = new RelationSentinel(); + $model->setRelateAs('not_a_real_tag'); + + $threw = false; + + try { + @$model->getActiveRelationKey(); + } catch (Throwable $e) { + $threw = true; + } + + $this->assertTrue( + $threw, + 'getActiveRelationKey() must fail loudly on an unknown relation tag, not return a silent null' + ); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index e5c5697..e19a4b0 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -138,3 +138,4 @@ public function has_cap($cap) require __DIR__ . '/Fixtures/Role.php'; require __DIR__ . '/Fixtures/Member.php'; require __DIR__ . '/Fixtures/PrefixedModel.php'; +require __DIR__ . '/Fixtures/RelationSentinel.php'; From 51db655ae7a34467231a0b8b8919a7f84e837f58 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Wed, 1 Jul 2026 11:51:14 +0600 Subject: [PATCH 087/102] fix: reject non-relation names in with/withCount/whereHas (option alpha) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relation resolution (prepareRelation, the single chokepoint for with/ withCount/whereHas/withWhereHas) previously called any method whose name matched a relation, gated only by method_exists — letting a framework Model method (e.g. refresh(), which runs a SELECT) or any consumer no-arg method run through a relation name, and failing messily on typos. Now: a name declared on the framework Model is rejected WITHOUT being called (declaring-class identity via ReflectionMethod, compared to Model::class so it holds under php-scoper; memoized per class::method); a consumer-declared method is called and its return validated as a relation query (QueryBuilder whose model has an active relation key) or rejected with a RuntimeException. A name that fails method_exists is still silently skipped (zero-BC for optional/typo'd relation lists). getActiveRelationKey() now throws on an unknown relation tag instead of returning a silent null. Turns the RED RelationResolutionSafetyTest green (216 tests, all pass). Assisted-By: AI --- docs/relations.md | 9 ++++ src/Concerns/Relations.php | 94 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 100 insertions(+), 3 deletions(-) diff --git a/docs/relations.md b/docs/relations.md index 4fa2a73..8348c04 100644 --- a/docs/relations.md +++ b/docs/relations.md @@ -242,6 +242,15 @@ which is meaningful only for `withCount` and `withExists`. ## Limitations +- **Relation names must not be untrusted input.** `with()`, `whereHas()`, + `withCount()` (and the other `with*` aggregates) resolve a relation by calling + the model method of that name. A name that is not a relation is rejected with a + `RuntimeException` ("Relation [x] is not defined on [Class]."), and framework + `Model` methods are rejected **without** being called — but a consumer model's + own no-arg method is invoked once before its non-relation return is discarded. + Pass only trusted, code-defined relation names (same contract as Eloquent), + never a raw request value. + - **`belongsTo` and `hasOne` are the same alias; key naming is reversed from Laravel.** Both set the `oneToOne` relation. `$foreignKey` is the column on the **related** table and `$localKey` is the column on the **calling** model's diff --git a/src/Concerns/Relations.php b/src/Concerns/Relations.php index f32e6aa..8ae2126 100644 --- a/src/Concerns/Relations.php +++ b/src/Concerns/Relations.php @@ -23,6 +23,9 @@ trait Relations private $_relationKeys = []; + /** Memoized framework-vs-consumer verdict per "class::method". */ + private static $relationMethodCache = []; + /** * Undocumented function. * @@ -185,7 +188,14 @@ public function getRelationalKeys() */ public function getActiveRelationKey() { - return $this->getRelationalKeys()[$this->getRelateAs()]; + $relateAs = $this->getRelateAs(); + $relationalKeys = $this->getRelationalKeys(); + + if (!isset($relationalKeys[$relateAs])) { + throw new RuntimeException('No relation keys for relation tag [' . $relateAs . '].'); + } + + return $relationalKeys[$relateAs]; } /** @@ -200,14 +210,14 @@ public function prepareRelation(array $relations): array $preparedRelation = []; foreach ($relations as $key => $value) { if (\is_int($key) && \is_string($value) && ($method = explode(' ', $value)[0]) && method_exists($this, $method)) { - $preparedRelation[$value] = $this->{$method}(); + $preparedRelation[$value] = $this->resolveRelationQuery($method); unset($relations[$key]); } } foreach ($relations as $key => $value) { if (\is_string($key) && ($method = explode(' ', $key)[0]) && method_exists($this, $method)) { - $preparedRelation[$key] = $this->{$method}(); + $preparedRelation[$key] = $this->resolveRelationQuery($method); if ($value instanceof Closure) { $value($preparedRelation[$key]); } @@ -217,6 +227,84 @@ public function prepareRelation(array $relations): array return $preparedRelation; } + /** + * Resolves an existing method name to its relation query, or fails loudly. + * + * The name has already passed method_exists() — a missing name is silently + * skipped by the caller (zero-BC for optional/typo'd relation lists). A method + * declared on the framework Model is rejected WITHOUT being called, so a + * side-effecting method (e.g. refresh()) can never run through a relation + * name; a consumer-declared method is called and its return validated. + * + * @param string $method + * + * @return QueryBuilder + */ + private function resolveRelationQuery($method) + { + if ($this->isFrameworkModelMethod($method)) { + throw new RuntimeException($this->undefinedRelationMessage($method)); + } + + $result = $this->{$method}(); + + if (!$this->isRelationQuery($result)) { + throw new RuntimeException($this->undefinedRelationMessage($method)); + } + + return $result; + } + + /** + * True when $method is declared on the framework Model itself (traits flatten + * into the using class, so relation/write helpers all report Model as their + * declaring class) rather than on a consumer subclass. Compares against + * Model::class so the check survives php-scoper's namespace prefixing. + * + * @param string $method + * + * @return bool + */ + private function isFrameworkModelMethod($method) + { + $cacheKey = \get_class($this) . '::' . $method; + if (isset(self::$relationMethodCache[$cacheKey])) { + return self::$relationMethodCache[$cacheKey]; + } + + $declaringClass = (new \ReflectionMethod($this, $method))->getDeclaringClass()->getName(); + + return self::$relationMethodCache[$cacheKey] = $declaringClass === Model::class; + } + + /** + * True when $result is a relation query: a QueryBuilder whose model carries + * an active relation-key entry. + * + * @param mixed $result + * + * @return bool + */ + private function isRelationQuery($result) + { + if (!$result instanceof QueryBuilder) { + return false; + } + + try { + $result->getModel()->getActiveRelationKey(); + } catch (RuntimeException $e) { + return false; + } + + return true; + } + + private function undefinedRelationMessage($method) + { + return 'Relation [' . $method . '] is not defined on [' . \get_class($this) . '].'; + } + public function prepareRelationName(string $relationName): array { $name = $relationName; From cf1055fc1505048a0fdedfccd05134fe3b149675 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Wed, 1 Jul 2026 13:02:39 +0600 Subject: [PATCH 088/102] docs: note non-obvious v2.0 migration gotchas (from bit-pi audit) Add the empirically-confirmed footguns a v1 consumer hits, under their existing sections: - 2.1 an is_array($result) "got rows?" guard now inverts (Collection when rows exist, [] when empty) -> silent data loss; use empty()/!empty(). - 2.2 $model->update([...])->save() fatals ("save() on false") on a 0-row update -> drop the trailing ->save(). - 2.4 a function/expression in select() only "survives" if it contains a '.', so CONCAT(url,...) breaks on dotless hosts (localhost); use selectRaw. - 2.9 a leftover no-arg ->withCount() throws ArgumentCountError, including when reached via eager-load relation resolution. Assisted-By: AI --- docs/breaking-changes.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs/breaking-changes.md b/docs/breaking-changes.md index 1cbc66c..7422988 100644 --- a/docs/breaking-changes.md +++ b/docs/breaking-changes.md @@ -72,6 +72,12 @@ $users->all(); // underlying plain array $users->toArray(); // array of model arrays ``` +> **Silent-data-loss trap:** an `if (is_array($result)) { … }` "got rows?" guard +> now **inverts** — a non-empty read is a `Collection` (`is_array` → false) while +> a zero-row read is a real `[]` (`is_array` → true) — so the branch runs only +> when there is *nothing* to process, silently dropping data whenever rows exist. +> Replace such guards with `empty($result)` / `!empty($result)`. + --- ### 2.2 `QueryBuilder::update()` is no longer chainable and executes immediately @@ -94,6 +100,13 @@ attributes and executes. **Migration:** set conditions **before** `update()`; drop trailing `->save()`/`->exec()`. +> **Runtime fatal on a chained `->save()`:** `update()` returns the *result* +> (`Model|false` for an existing model, `int|false` for a fresh one), never the +> builder — so `$model->update([...])->save()` fatals (`Call to a member function +> save() on false`) whenever the UPDATE changes **0 rows** (e.g. an idempotent +> re-save where no value actually differs, which MySQL reports as 0 affected +> rows). Drop the trailing `->save()` — `update()` already persisted. + --- ### 2.3 `QueryBuilder::save()` return value changed @@ -143,6 +156,13 @@ column and keeps the alias separate, so `->select(['id', 'title AS t'])` emits ->selectRaw('SUM(amount) as amt', $bindings) ``` +> **Gotcha — an expression may "accidentally" survive:** `prepareColumnName()` +> passes a column through untouched only when it already contains a `.`. So +> `select(['CONCAT("https://example.com/…", col) as x'])` emits valid SQL *merely* +> because the URL contains a dot — the identical code breaks on a dotless host +> (`http://localhost/…`). Never rely on this; route any function/expression +> through `selectRaw()`. + --- ### 2.5 `delete()` with no `WHERE` clause no longer wipes the table @@ -243,6 +263,11 @@ User::withCount('posts')->get(); // adds posts_count sub-select **Migration:** for a plain row count use `->count()`; for relation counts use `withCount($relation)`. See §4.2 for the full aggregate family. +> **Runtime fatal:** a leftover no-arg `->withCount()` — including one buried in +> a relation method that is later eager-loaded via `with('rel')` (the relation +> resolver invokes the method to validate it) — now throws `ArgumentCountError`; +> the relation-name parameter is required. + --- ### 2.10 `addRelation()` signature changed From a37a8853eb6cb6ad2f3efa1794b4cf5f37e3e31c Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Wed, 1 Jul 2026 13:16:15 +0600 Subject: [PATCH 089/102] fix: save() treats a 0-row UPDATE as success, not failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit exec() returns rows-affected (0 for a no-op UPDATE) and false only on a real DB error or a cancelled pre-event. save()'s existing-model branch checked exec() for truthiness, so a valid UPDATE that changed no rows (an idempotent re-save where no value differs) returned false — misreporting success as failure. This was the root cause of `$model->update([...])->save()` fataling (false->save()) and made `if (!$model->save())` falsely error on no-op saves. Compare against false so any non-error result (including 0 rows) returns the Model and fires 'saved', consistent with the 'updated' event that already fired. Insert path and genuine-error path unchanged. Documented in breaking-changes.md §3 (+ §2.2 note updated). Adds SaveZeroRowUpdateFixTest (0-row returns Model, chained update()->save() no fatal, real error still false). Assisted-By: AI --- docs/breaking-changes.md | 17 +++++-- src/QueryBuilder.php | 3 +- tests/SaveZeroRowUpdateFixTest.php | 73 ++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 6 deletions(-) create mode 100644 tests/SaveZeroRowUpdateFixTest.php diff --git a/docs/breaking-changes.md b/docs/breaking-changes.md index 7422988..12012a0 100644 --- a/docs/breaking-changes.md +++ b/docs/breaking-changes.md @@ -100,12 +100,14 @@ attributes and executes. **Migration:** set conditions **before** `update()`; drop trailing `->save()`/`->exec()`. -> **Runtime fatal on a chained `->save()`:** `update()` returns the *result* +> **Don't chain after `update()`:** `update()` returns the *result* > (`Model|false` for an existing model, `int|false` for a fresh one), never the -> builder — so `$model->update([...])->save()` fatals (`Call to a member function -> save() on false`) whenever the UPDATE changes **0 rows** (e.g. an idempotent -> re-save where no value actually differs, which MySQL reports as 0 affected -> rows). Drop the trailing `->save()` — `update()` already persisted. +> builder, so a trailing builder call breaks. On an **existing** model +> `$model->update([...])->save()` now works — the `->save()` lands on the returned +> Model and no-ops (redundant) — but on a **fresh** model the `int` return still +> fatals (`save()` on int). Drop the trailing `->save()`; `update()` already +> persisted. (Before the `save()` 0-row fix in §3, the existing-model chain also +> fataled on any no-op update.) --- @@ -354,6 +356,11 @@ Not signature breaks, but observable runtime differences. Bulk inserts now return all created models. - **NULL columns persist as SQL `NULL`** in insert/update/upsert, instead of being coerced to an empty string `''`. +- **`save()` treats a 0-row UPDATE as success.** A successful UPDATE that changes + no rows (an idempotent re-save where no value differs) now returns the Model + instead of `false` — `exec()` returns `false` only on a real DB error/cancel, + so `save()` no longer misreports a no-op update as a failure. The insert path + and the genuine-error path (returns `false`) are unchanged. - **`paginate()`** defaults `select` to `*` when empty and computes the count before applying limit/offset; pagination with explicit `select` columns and count no longer conflict. diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index 8e0edad..747e621 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -1154,7 +1154,8 @@ public function save() $this->update = $columns; - if ($this->exec()) { + // 0-row (no-op) UPDATE is success; exec() is false only on error/cancel. + if ($this->exec() !== false) { $this->_model->fireEvent('saved'); return $this->_model; diff --git a/tests/SaveZeroRowUpdateFixTest.php b/tests/SaveZeroRowUpdateFixTest.php new file mode 100644 index 0000000..1f5fc5f --- /dev/null +++ b/tests/SaveZeroRowUpdateFixTest.php @@ -0,0 +1,73 @@ +save()` fatals on `false->save()` and `if (!$model->save())` + * falsely reports an error. A genuine error must still return false. + */ +final class SaveZeroRowUpdateFixTest extends TestCase +{ + protected function setUp(): void + { + $GLOBALS['wpdb'] = new FakeWpdb(); + } + + protected function tearDown(): void + { + $GLOBALS['wpdb'] = new FakeWpdb(); + } + + private function existingUser(): User + { + $GLOBALS['wpdb']->resolver = static function () { + return [(object) ['id' => 1, 'name' => 'Ada']]; + }; + + return User::query()->where('id', 1)->first(); + } + + public function testZeroRowUpdateReturnsModelNotFalse(): void + { + $user = $this->existingUser(); + $user->name = 'Grace'; // dirty -> a real UPDATE is built + + $GLOBALS['wpdb']->rows_affected = 0; // matched the row but changed nothing + $GLOBALS['wpdb']->last_error = ''; + + $result = $user->save(); + + $this->assertSame($user, $result, 'a 0-row (no-op) UPDATE is success, must return the Model'); + } + + public function testChainedUpdateSaveDoesNotFatalOnZeroRowUpdate(): void + { + $user = $this->existingUser(); + + $GLOBALS['wpdb']->rows_affected = 0; + + // update() delegates to save() on an existing model; the trailing save() + // must land on a Model, not false. + $result = $user->update(['name' => 'Grace'])->save(); + + $this->assertSame($user, $result); + } + + public function testRealErrorStillReturnsFalse(): void + { + $user = $this->existingUser(); + $user->name = 'Grace'; + + $GLOBALS['wpdb']->rows_affected = 0; + $GLOBALS['wpdb']->last_error = 'ER_SOMETHING'; // exec() -> false + + $this->assertFalse($user->save(), 'a real DB error must still return false'); + } +} From 273fe83f15854cbf8d34d65f5122e501ff8d0fdd Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Wed, 1 Jul 2026 13:24:13 +0600 Subject: [PATCH 090/102] fix: save() insert decides success from exec(), keeps lastInsertId() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit save()'s insert branch decided success by lastInsertId() ($wpdb->insert_id), so a successful INSERT into a table with a manual/composite key (insert_id 0) returned false and never fired 'saved'. It also risked returning the Model on a FAILED insert when insert_id held a stale non-zero value from an earlier insert in the same request. Decide success from exec() (false only on real error/cancel), then still assign the auto-increment id to the primary key when present. A manual/ composite-key insert now returns the Model; a genuine error returns false before touching lastInsertId (no stale PK, no 'saved'). Documented in breaking-changes.md §3. Adds SaveInsertReturnFixTest (auto-increment sets PK; manual-PK returns Model; error returns false). Assisted-By: AI --- docs/breaking-changes.md | 11 +++--- src/QueryBuilder.php | 14 ++++--- tests/SaveInsertReturnFixTest.php | 64 +++++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 10 deletions(-) create mode 100644 tests/SaveInsertReturnFixTest.php diff --git a/docs/breaking-changes.md b/docs/breaking-changes.md index 12012a0..36d3e90 100644 --- a/docs/breaking-changes.md +++ b/docs/breaking-changes.md @@ -356,11 +356,12 @@ Not signature breaks, but observable runtime differences. Bulk inserts now return all created models. - **NULL columns persist as SQL `NULL`** in insert/update/upsert, instead of being coerced to an empty string `''`. -- **`save()` treats a 0-row UPDATE as success.** A successful UPDATE that changes - no rows (an idempotent re-save where no value differs) now returns the Model - instead of `false` — `exec()` returns `false` only on a real DB error/cancel, - so `save()` no longer misreports a no-op update as a failure. The insert path - and the genuine-error path (returns `false`) are unchanged. +- **`save()` decides success from the query result, not the affected/returned id.** + A successful UPDATE that changes no rows (an idempotent re-save where no value + differs) and a successful INSERT into a table with a manual/composite key (no + auto-increment id) both now return the Model instead of `false` — `exec()` + returns `false` only on a real DB error/cancel. The auto-increment id is still + assigned to the primary key when present. Genuine errors still return `false`. - **`paginate()`** defaults `select` to `*` when empty and computes the count before applying limit/offset; pagination with explicit `select` columns and count no longer conflict. diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index 747e621..c1aad5d 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -1165,15 +1165,19 @@ public function save() } $this->insert = $columns; - $this->exec(); + if ($this->exec() === false) { + return false; + } + + // Set the PK from the auto-increment id when there is one; a table with a + // manual/composite key returns insert_id 0 yet the insert still succeeded. if ($insertId = $this->lastInsertId()) { $this->_model->setAttribute($pk, $insertId); - $this->_model->fireEvent('saved'); - - return $this->_model; } - return false; + $this->_model->fireEvent('saved'); + + return $this->_model; } /** diff --git a/tests/SaveInsertReturnFixTest.php b/tests/SaveInsertReturnFixTest.php new file mode 100644 index 0000000..3147735 --- /dev/null +++ b/tests/SaveInsertReturnFixTest.php @@ -0,0 +1,64 @@ +insert_id = 7; + $GLOBALS['wpdb']->rows_affected = 1; + + $user = new User(); + $user->name = 'Ada'; + $result = $user->save(); + + $this->assertSame($user, $result); + $this->assertEquals(7, $user->getAttribute('id'), 'auto-increment id must be set from lastInsertId()'); + } + + public function testManualPkInsertReturnsModelWhenNoAutoIncrementId(): void + { + $GLOBALS['wpdb']->insert_id = 0; // manual/composite key -> no auto id + $GLOBALS['wpdb']->rows_affected = 1; // insert succeeded + $GLOBALS['wpdb']->last_error = ''; + + $user = new User(); + $user->name = 'Ada'; + $result = $user->save(); + + $this->assertSame($user, $result, 'successful insert with no auto-increment id must return the Model'); + } + + public function testInsertErrorStillReturnsFalse(): void + { + $GLOBALS['wpdb']->insert_id = 0; + $GLOBALS['wpdb']->last_error = 'ER_DUP_ENTRY'; // exec() -> false + + $user = new User(); + $user->name = 'Ada'; + + $this->assertFalse($user->save(), 'a failed insert must return false'); + } +} From 4292e159d67c0ec5652279329af75ba6277b5d3b Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Wed, 1 Jul 2026 13:32:07 +0600 Subject: [PATCH 091/102] test: cover session-change edge cases; fix null-offset deprecation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the edge cases the session's fixes introduced but left unverified: - soft-delete: refresh() reloads a trashed row without the scope filter (withTrashed) so exists() stays true. - relation guard: a method returning a plain QueryBuilder with no active relation key is rejected; a framework Model method is rejected; the 4th entry point withWhereHas() rejects a non-relation too. - save() insert: a failed insert carrying a stale non-zero insert_id returns false and does not set the PK from it. - saved event fires on a 0-row UPDATE and a manual-PK INSERT, and never on a failed write (new SavedEventUser fixture). getActiveRelationKey() short-circuits a null relation tag before the isset() so it no longer trips PHP 8's "null as array offset" deprecation (surfaced by the plain-QueryBuilder rejection test). Behavior unchanged — both still throw. Assisted-By: AI --- src/Concerns/Relations.php | 3 +- tests/Fixtures/RelationSentinel.php | 6 +++ tests/Fixtures/SavedEventUser.php | 29 +++++++++++ tests/RelationResolutionSafetyTest.php | 26 ++++++++++ tests/SaveEventFiresTest.php | 69 ++++++++++++++++++++++++++ tests/SaveInsertReturnFixTest.php | 13 +++++ tests/SoftDeleteScopeTest.php | 18 +++++++ tests/bootstrap.php | 1 + 8 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 tests/Fixtures/SavedEventUser.php create mode 100644 tests/SaveEventFiresTest.php diff --git a/src/Concerns/Relations.php b/src/Concerns/Relations.php index 8ae2126..305d6a2 100644 --- a/src/Concerns/Relations.php +++ b/src/Concerns/Relations.php @@ -191,7 +191,8 @@ public function getActiveRelationKey() $relateAs = $this->getRelateAs(); $relationalKeys = $this->getRelationalKeys(); - if (!isset($relationalKeys[$relateAs])) { + // Short-circuit a null tag: isset($array[null]) is a deprecated null offset. + if ($relateAs === null || !isset($relationalKeys[$relateAs])) { throw new RuntimeException('No relation keys for relation tag [' . $relateAs . '].'); } diff --git a/tests/Fixtures/RelationSentinel.php b/tests/Fixtures/RelationSentinel.php index ad0a253..dc5ec3f 100644 --- a/tests/Fixtures/RelationSentinel.php +++ b/tests/Fixtures/RelationSentinel.php @@ -25,4 +25,10 @@ public function destroyTheWorld() { return 'not a query builder'; } + + /** Returns a QueryBuilder, but NOT a relation query (no active relation key). */ + public function plainQuery() + { + return self::query(); + } } diff --git a/tests/Fixtures/SavedEventUser.php b/tests/Fixtures/SavedEventUser.php new file mode 100644 index 0000000..7fde220 --- /dev/null +++ b/tests/Fixtures/SavedEventUser.php @@ -0,0 +1,29 @@ +assertRejectedAsRelation(static function () { + RelationSentinel::with('plainQuery'); + }); + } + + // A framework Model method (refresh() runs a SELECT) must be rejected — and, + // per option-α, never invoked. + public function testFrameworkModelMethodIsRejected(): void + { + $this->assertRejectedAsRelation(static function () { + RelationSentinel::with('refresh'); + }); + } + + // withWhereHas() is the 4th resolution entry point and must reject too. + public function testWithWhereHasRejectsNonRelationMethod(): void + { + $this->assertRejectedAsRelation(static function () { + RelationSentinel::withWhereHas('destroyTheWorld'); + }); + } } diff --git a/tests/SaveEventFiresTest.php b/tests/SaveEventFiresTest.php new file mode 100644 index 0000000..b961e7b --- /dev/null +++ b/tests/SaveEventFiresTest.php @@ -0,0 +1,69 @@ +resolver = static function () { + return [(object) ['id' => 1, 'name' => 'Ada']]; + }; + + $user = SavedEventUser::query()->where('id', 1)->first(); // existing + $user->name = 'Grace'; + + $GLOBALS['wpdb']->rows_affected = 0; // no-op UPDATE + SavedEventUser::$savedCount = 0; // ignore anything fired during load + + $user->save(); + + $this->assertSame(1, SavedEventUser::$savedCount, 'saved must fire on a 0-row UPDATE'); + } + + public function testSavedFiresOnManualPkInsert(): void + { + $GLOBALS['wpdb']->insert_id = 0; // no auto-increment id + $GLOBALS['wpdb']->rows_affected = 1; // insert succeeded + $GLOBALS['wpdb']->last_error = ''; + + $user = new SavedEventUser(); + $user->name = 'Ada'; + $user->save(); + + $this->assertSame(1, SavedEventUser::$savedCount, 'saved must fire on a successful manual-PK INSERT'); + } + + public function testSavedDoesNotFireOnFailedWrite(): void + { + $GLOBALS['wpdb']->last_error = 'ER_DUP_ENTRY'; // exec() -> false + + $user = new SavedEventUser(); + $user->name = 'Ada'; + $result = $user->save(); + + $this->assertFalse($result); + $this->assertSame(0, SavedEventUser::$savedCount, 'saved must NOT fire when the write fails'); + } +} diff --git a/tests/SaveInsertReturnFixTest.php b/tests/SaveInsertReturnFixTest.php index 3147735..82f0e41 100644 --- a/tests/SaveInsertReturnFixTest.php +++ b/tests/SaveInsertReturnFixTest.php @@ -61,4 +61,17 @@ public function testInsertErrorStillReturnsFalse(): void $this->assertFalse($user->save(), 'a failed insert must return false'); } + + public function testFailedInsertWithStaleInsertIdReturnsFalseAndDoesNotSetPk(): void + { + $GLOBALS['wpdb']->insert_id = 99; // stale id from an earlier insert + $GLOBALS['wpdb']->last_error = 'ER_DUP_ENTRY'; // this insert failed -> exec() false + + $user = new User(); + $user->name = 'Ada'; + $result = $user->save(); + + $this->assertFalse($result, 'a failed insert must return false even with a stale insert_id'); + $this->assertNotEquals(99, $user->getAttribute('id'), 'a failed insert must not set the PK from a stale insert_id'); + } } diff --git a/tests/SoftDeleteScopeTest.php b/tests/SoftDeleteScopeTest.php index 0e13578..8a956e0 100644 --- a/tests/SoftDeleteScopeTest.php +++ b/tests/SoftDeleteScopeTest.php @@ -113,4 +113,22 @@ public function testOnlyTrashedOrWhereGroupsUserConditions(): void $this->assertMatchesRegularExpression('/\(.*status.*OR.*status.*\)/s', $sql); $this->assertMatchesRegularExpression('/\).*deleted_at.*IS\s+NOT\s+NULL/s', $sql); } + + // refresh() reloads a row by PK with withTrashed(), so a trashed row is still + // found and exists() stays true (no re-INSERT on the next save()). + public function testRefreshReloadsTrashedRowWithoutScopeFilter(): void + { + $GLOBALS['wpdb']->resolver = static function () { + return [(object) ['id' => 1, 'deleted_at' => '2026-01-01 00:00:00']]; + }; + + $post = new SoftPost(1); // constructor triggers refresh() + + $this->assertTrue($post->exists(), 'refresh() must find the row even when trashed'); + $this->assertStringNotContainsString( + 'deleted_at', + $GLOBALS['wpdb']->last_query, + 'refresh() must not scope out trashed rows (withTrashed)' + ); + } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index e19a4b0..3d2d749 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -127,6 +127,7 @@ public function has_cap($cap) require __DIR__ . '/Fixtures/User.php'; require __DIR__ . '/Fixtures/SoftPost.php'; require __DIR__ . '/Fixtures/EventUser.php'; +require __DIR__ . '/Fixtures/SavedEventUser.php'; require __DIR__ . '/Fixtures/RetrieveUser.php'; require __DIR__ . '/Fixtures/CastModel.php'; require __DIR__ . '/Fixtures/CastAliasModel.php'; From 21c4fd696fb500625c468bfeb0c64683c881b5c8 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Wed, 1 Jul 2026 13:45:27 +0600 Subject: [PATCH 092/102] test: close remaining coverage gaps (memoization, base-class relation, events) Cover the three edges previously flagged as skipped: - relation guard memoizes the framework-vs-consumer verdict per class::method (reflection-asserted: refresh -> true, posts -> false, no collision). - a relation declared on an intermediate base class (leaf -> base -> Model) resolves, since its declaring class is the base, not the framework Model. - created fires on a successful INSERT and updated fires even on a 0-row UPDATE (SavedEventUser gains created/updated counters). New fixtures RelationBaseModel/RelationLeafModel. The memoization test guards ReflectionProperty::setAccessible() behind PHP_VERSION_ID (required on 7.4, a deprecated no-op on 8.1+) to keep the suite deprecation-clean. Assisted-By: AI --- tests/Fixtures/RelationBaseModel.php | 22 +++++++++++++ tests/Fixtures/RelationLeafModel.php | 11 +++++++ tests/Fixtures/SavedEventUser.php | 17 ++++++++++ tests/RelationResolutionSafetyTest.php | 44 ++++++++++++++++++++++++++ tests/SaveEventFiresTest.php | 39 ++++++++++++++++++++--- tests/bootstrap.php | 2 ++ 6 files changed, 130 insertions(+), 5 deletions(-) create mode 100644 tests/Fixtures/RelationBaseModel.php create mode 100644 tests/Fixtures/RelationLeafModel.php diff --git a/tests/Fixtures/RelationBaseModel.php b/tests/Fixtures/RelationBaseModel.php new file mode 100644 index 0000000..99b1b87 --- /dev/null +++ b/tests/Fixtures/RelationBaseModel.php @@ -0,0 +1,22 @@ +hasMany(Post::class, 'base_id', 'id'); + } +} diff --git a/tests/Fixtures/RelationLeafModel.php b/tests/Fixtures/RelationLeafModel.php new file mode 100644 index 0000000..28d94d2 --- /dev/null +++ b/tests/Fixtures/RelationLeafModel.php @@ -0,0 +1,11 @@ + base -> Model) + // must resolve — its declaring class is the base, not the framework Model. + public function testRelationOnIntermediateBaseClassIsAllowed(): void + { + $threw = false; + + try { + RelationLeafModel::with('widgets'); + } catch (Throwable $e) { + $threw = true; + } + + $this->assertFalse($threw, 'a relation declared on an intermediate base class must resolve'); + } + + // The framework-vs-consumer verdict is memoized per "class::method"; the + // cache must hold the correct boolean for each and not collide across methods. + public function testFrameworkVerdictIsMemoizedPerClassMethod(): void + { + $cacheProp = new ReflectionProperty(Model::class, 'relationMethodCache'); + if (\PHP_VERSION_ID < 80100) { + $cacheProp->setAccessible(true); // required on 7.4; a deprecated no-op on 8.1+ + } + $cacheProp->setValue(null, []); + + try { + RelationSentinel::with('refresh'); // framework method -> rejected + } catch (RuntimeException $e) { + // expected + } + RelationSentinel::with('posts'); // consumer relation -> resolves + + $cache = $cacheProp->getValue(); + $prefix = RelationSentinel::class; + + $this->assertArrayHasKey($prefix . '::refresh', $cache); + $this->assertTrue($cache[$prefix . '::refresh'], 'refresh memoized as a framework method'); + $this->assertArrayHasKey($prefix . '::posts', $cache); + $this->assertFalse($cache[$prefix . '::posts'], 'posts memoized as a consumer method'); + } } diff --git a/tests/SaveEventFiresTest.php b/tests/SaveEventFiresTest.php index b961e7b..14401ce 100644 --- a/tests/SaveEventFiresTest.php +++ b/tests/SaveEventFiresTest.php @@ -15,14 +15,14 @@ final class SaveEventFiresTest extends TestCase { protected function setUp(): void { - $GLOBALS['wpdb'] = new FakeWpdb(); - SavedEventUser::$savedCount = 0; + $GLOBALS['wpdb'] = new FakeWpdb(); + SavedEventUser::resetCounters(); } protected function tearDown(): void { - $GLOBALS['wpdb'] = new FakeWpdb(); - SavedEventUser::$savedCount = 0; + $GLOBALS['wpdb'] = new FakeWpdb(); + SavedEventUser::resetCounters(); } public function testSavedFiresOnZeroRowUpdate(): void @@ -35,13 +35,42 @@ public function testSavedFiresOnZeroRowUpdate(): void $user->name = 'Grace'; $GLOBALS['wpdb']->rows_affected = 0; // no-op UPDATE - SavedEventUser::$savedCount = 0; // ignore anything fired during load + SavedEventUser::resetCounters(); // ignore anything fired during load $user->save(); $this->assertSame(1, SavedEventUser::$savedCount, 'saved must fire on a 0-row UPDATE'); } + public function testUpdatedFiresOnZeroRowUpdate(): void + { + $GLOBALS['wpdb']->resolver = static function () { + return [(object) ['id' => 1, 'name' => 'Ada']]; + }; + + $user = SavedEventUser::query()->where('id', 1)->first(); + $user->name = 'Grace'; + + $GLOBALS['wpdb']->rows_affected = 0; + SavedEventUser::resetCounters(); + + $user->save(); + + $this->assertSame(1, SavedEventUser::$updatedCount, 'updated must fire even on a 0-row UPDATE'); + } + + public function testCreatedFiresOnInsert(): void + { + $GLOBALS['wpdb']->insert_id = 5; + $GLOBALS['wpdb']->rows_affected = 1; + + $user = new SavedEventUser(); + $user->name = 'Ada'; + $user->save(); + + $this->assertSame(1, SavedEventUser::$createdCount, 'created must fire on a successful INSERT'); + } + public function testSavedFiresOnManualPkInsert(): void { $GLOBALS['wpdb']->insert_id = 0; // no auto-increment id diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 3d2d749..a84c415 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -140,3 +140,5 @@ public function has_cap($cap) require __DIR__ . '/Fixtures/Member.php'; require __DIR__ . '/Fixtures/PrefixedModel.php'; require __DIR__ . '/Fixtures/RelationSentinel.php'; +require __DIR__ . '/Fixtures/RelationBaseModel.php'; +require __DIR__ . '/Fixtures/RelationLeafModel.php'; From 6d8b1d3d183f39ada211caf7b608bde2ba9ecfc5 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Wed, 1 Jul 2026 14:13:50 +0600 Subject: [PATCH 093/102] build: require PHP >=8.2, add phpunit dev-dep + composer test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raise the minimum PHP from ^7.4 || ^8.0 to >=8.2 (breaking: the package no longer installs on PHP < 8.2). Add phpunit/phpunit ^11.5 as a dev dependency and a `composer test` script so tests run from the vendored PHPUnit instead of a gitignored phpunit.phar. Point the compat gate at 8.2- and pin config.platform.php to 8.2.0 for reproducible installs. Documented in breaking-changes.md §2.14 (+ summary row 14). Assisted-By: AI --- composer.json | 11 ++++++++--- docs/breaking-changes.md | 16 ++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index 23eacd1..3dfd57f 100644 --- a/composer.json +++ b/composer.json @@ -29,7 +29,7 @@ ] }, "require": { - "php": "^7.4 || ^8.0" + "php": ">=8.2" }, "autoload": { "psr-4": { @@ -37,14 +37,16 @@ } }, "scripts": { + "test": "phpunit", "lint": "./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.php", - "compat": "./vendor/bin/phpcs -p ./src --standard=PHPCompatibilityWP --runtime-set testVersion 7.4-" + "compat": "./vendor/bin/phpcs -p ./src --standard=PHPCompatibilityWP --runtime-set testVersion 8.2-" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.10", "sirbrillig/phpcs-variable-analysis": "*", "dealerdirect/phpcodesniffer-composer-installer": "^0.7", - "phpcompatibility/phpcompatibility-wp": "*" + "phpcompatibility/phpcompatibility-wp": "*", + "phpunit/phpunit": "^11.5" }, "extra": { "branch-alias": { @@ -52,6 +54,9 @@ } }, "config": { + "platform": { + "php": "8.2.0" + }, "allow-plugins": { "dealerdirect/phpcodesniffer-composer-installer": true } diff --git a/docs/breaking-changes.md b/docs/breaking-changes.md index 36d3e90..b12df92 100644 --- a/docs/breaking-changes.md +++ b/docs/breaking-changes.md @@ -30,6 +30,7 @@ API and runtime behavior change, it is a **major** version bump. | 11 | Blueprint | `binary()` removed | Low | | 12 | QueryBuilder | exception type/message changed in `exec()` | Low | | 13 | Model | soft-delete reads exclude trashed by default (opt out with `$soft_delete_scope = false`) | Medium | +| 14 | Composer | minimum PHP raised to **8.2** (was 7.4) — package no longer installs on PHP < 8.2 | High | --- @@ -342,6 +343,21 @@ re-hydrating a trashed model still reports `exists() === true`. --- +### 2.14 Minimum PHP is now 8.2 + +`composer.json` `require.php` changed from `^7.4 || ^8.0` to `>=8.2`. The package +no longer installs on PHP 7.4, 8.0, or 8.1. + +**Why it breaks:** a plugin whose own `require.php` still allows < 8.2 can no +longer resolve this package version via Composer. + +**Migration:** raise the consuming plugin's minimum PHP to 8.2 (and its runtime) +before upgrading. Stay on the previous release if you must support older PHP. +The test suite runs on PHPUnit 11 (`composer test`); the compatibility gate now +targets `8.2-`. + +--- + ## 3. Behavioral changes Not signature breaks, but observable runtime differences. From 9ad27f88a3d428c8b9391b9e632e39a82e3c68f4 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Thu, 2 Jul 2026 13:38:37 +0600 Subject: [PATCH 094/102] feat: add distinct() to query builder QueryBuilder had a $distinct property but no method to set it and Grammar never read it, so ->distinct() threw "Call to undefined method". Add distinct()/isDistinct() and emit SELECT DISTINCT. Additive: default false keeps existing SELECTs byte-identical. Assisted-By: AI --- src/Query/Grammar.php | 2 +- src/QueryBuilder.php | 23 +++++++++++++++++++++++ tests/Query/GrammarTest.php | 16 ++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/Query/Grammar.php b/src/Query/Grammar.php index 2fbbe0a..aad15e1 100644 --- a/src/Query/Grammar.php +++ b/src/Query/Grammar.php @@ -28,7 +28,7 @@ public function compileSelect(QueryBuilder $query): string { $query->resetBindings(); - $sql = 'SELECT ' . implode(',', $query->select); + $sql = 'SELECT ' . ($query->isDistinct() ? 'DISTINCT ' : '') . implode(',', $query->select); $sql .= $this->prepareRawSelect($query); $sql .= ' FROM ' . $query->getTable(); $sql .= $this->getFrom($query); diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index c1aad5d..b497d5c 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -461,6 +461,29 @@ public function selectRaw($column, array $bindings = []) return $this; } + /** + * Adds DISTINCT to the SELECT. Note: this does not emit COUNT(DISTINCT ...) + * and is not pagination-aware (count()/paginate() ignore it). + * + * @return $this + */ + public function distinct() + { + $this->distinct = true; + + return $this; + } + + /** + * Whether DISTINCT was requested for this SELECT. + * + * @return bool + */ + public function isDistinct() + { + return $this->distinct; + } + /** * Prepare the query and execute. * diff --git a/tests/Query/GrammarTest.php b/tests/Query/GrammarTest.php index f72c60d..4a9d2fc 100644 --- a/tests/Query/GrammarTest.php +++ b/tests/Query/GrammarTest.php @@ -137,4 +137,20 @@ public function testCompileSelectReturnsString(): void $this->assertIsString($query->grammar()->compileSelect($query)); $this->assertInstanceOf(QueryBuilder::class, $query); } + + public function testDistinctEmitsSelectDistinct(): void + { + $this->assertSame( + 'SELECT DISTINCT `wp_users`.`id` FROM wp_users', + (new User())->select('id')->distinct()->toSql() + ); + } + + public function testWithoutDistinctSelectIsUnchanged(): void + { + $this->assertSame( + 'SELECT `wp_users`.`id` FROM wp_users', + (new User())->select('id')->toSql() + ); + } } From 686b0b421e4684331d40785e495e90b131ef098a Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Thu, 2 Jul 2026 13:39:58 +0600 Subject: [PATCH 095/102] feat: add avg()/sum(); validate aggregate function name avg()/sum() were missing (only count/max/min existed), so calling them directly threw while aggregate('avg', ...) worked. Add both as wrappers. Also guard aggregate()'s $function, which is interpolated straight into SQL, with a bare-identifier regex (strtoupper + [A-Za-z_][A-Za-z0-9_]*). This blocks the injection surface without a fixed allowlist, so real aggregates like GROUP_CONCAT/STDDEV keep working (no BC break). Assisted-By: AI --- src/Model.php | 2 ++ src/QueryBuilder.php | 17 ++++++++++ tests/AggregateMethodsTest.php | 61 ++++++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+) create mode 100644 tests/AggregateMethodsTest.php diff --git a/src/Model.php b/src/Model.php index 897c1d4..d409439 100644 --- a/src/Model.php +++ b/src/Model.php @@ -85,6 +85,8 @@ * @method static int count() * @method static mixed max($column) * @method static mixed min($column) + * @method static mixed avg($column) + * @method static mixed sum($column) * @method static bool|string delete() * @method static string|bool forceDelete() * @method static string|bool|Model restore() diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index b497d5c..4f8c87a 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -1223,8 +1223,25 @@ public function min($column) return $this->aggregate('MIN', $column); } + public function avg($column) + { + return $this->aggregate('AVG', $column); + } + + public function sum($column) + { + return $this->aggregate('SUM', $column); + } + public function aggregate($function, $column) { + $function = strtoupper((string) $function); + // $function is interpolated straight into SQL; allow only a bare + // identifier so parens/spaces/semicolons can't smuggle in a payload. + if (!preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $function)) { + throw new RuntimeException('Invalid aggregate function name.'); + } + $query = $this->clone(); $query->select = []; $query->selectRaw = ['columns' => [], 'bindings' => []]; diff --git a/tests/AggregateMethodsTest.php b/tests/AggregateMethodsTest.php new file mode 100644 index 0000000..f80e8b6 --- /dev/null +++ b/tests/AggregateMethodsTest.php @@ -0,0 +1,61 @@ +resolver = function () { + return [(object) ['AVG' => '42.5']]; + }; + + $this->assertSame('42.5', User::query()->avg('score')); + $this->assertStringContainsString('AVG(', $GLOBALS['wpdb']->last_query); + } + + public function testSumReturnsValue(): void + { + $GLOBALS['wpdb']->resolver = function () { + return [(object) ['SUM' => '100']]; + }; + + $this->assertSame('100', User::query()->sum('score')); + $this->assertStringContainsString('SUM(', $GLOBALS['wpdb']->last_query); + } + + public function testAggregateRejectsNonIdentifierFunctionName(): void + { + $this->expectException(RuntimeException::class); + + User::query()->aggregate('COUNT(*); DROP TABLE users; --', 'id'); + } + + public function testAggregateAllowsNonAllowlistedAggregate(): void + { + $GLOBALS['wpdb']->resolver = function () { + return [(object) ['GROUP_CONCAT' => 'a,b']]; + }; + + $this->assertSame('a,b', User::query()->aggregate('GROUP_CONCAT', 'score')); + } +} From b4c49ef1d860dee9acbe8f4c1cf7e322b3641f25 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Thu, 2 Jul 2026 13:44:22 +0600 Subject: [PATCH 096/102] fix: resolve unprefixed model/join table names in qualified columns A column qualified by the unprefixed model or joined table name (e.g. custom_apps.id) was emitted verbatim while the physical table is wp_custom_apps, producing "unknown column/table" errors. QueryBuilder now exposes an unprefixed->physical table map (model table + non-aliased joins) and resolveQualifier(); Grammar applies it at compile time to SELECT columns, WHERE/HAVING/JOIN-ON columns, GROUP BY and ORDER BY. Resolution is order-independent and idempotent. Aliases win over the map, and already-physical / unknown qualifiers pass through untouched, so only already-broken SQL changes (BC-safe). Three bug-locking tests that pinned the old passthrough are updated to assert the resolved output. Residual (documented): a joined-table qualifier inside a nested where() closure is not resolved (the child builder has no joins). Assisted-By: AI --- src/Query/Grammar.php | 11 ++-- src/QueryBuilder.php | 81 +++++++++++++++++++++-- tests/QualifiedColumnPrefixTest.php | 95 +++++++++++++++++++++++++++ tests/Query/GrammarTest.php | 5 +- tests/QueryFeaturesTest.php | 5 +- tests/SelectJoinAggregateEdgeTest.php | 5 +- 6 files changed, 187 insertions(+), 15 deletions(-) create mode 100644 tests/QualifiedColumnPrefixTest.php diff --git a/src/Query/Grammar.php b/src/Query/Grammar.php index aad15e1..ea1441b 100644 --- a/src/Query/Grammar.php +++ b/src/Query/Grammar.php @@ -28,7 +28,8 @@ public function compileSelect(QueryBuilder $query): string { $query->resetBindings(); - $sql = 'SELECT ' . ($query->isDistinct() ? 'DISTINCT ' : '') . implode(',', $query->select); + $columns = array_map([$query, 'resolveQualifier'], $query->select); + $sql = 'SELECT ' . ($query->isDistinct() ? 'DISTINCT ' : '') . implode(',', $columns); $sql .= $this->prepareRawSelect($query); $sql .= ' FROM ' . $query->getTable(); $sql .= $this->getFrom($query); @@ -148,7 +149,7 @@ private function getGroupBy(QueryBuilder $query) return ''; } - return ' GROUP BY ' . implode(',', $groupBy); + return ' GROUP BY ' . implode(',', array_map([$query, 'resolveQualifier'], $groupBy)); } /** @@ -184,7 +185,7 @@ private function getOrderBy(QueryBuilder $query) $sql .= $order['raw'] . ', '; $query->addBindings($order['bindings']); } elseif (isset($order['column'])) { - $sql .= $order['column'] . ' ' . $order['direction'] . ', '; + $sql .= $query->resolveQualifier($order['column']) . ' ' . $order['direction'] . ', '; } } @@ -258,7 +259,7 @@ private function prepareRawSelect(QueryBuilder $query) private function prepareColumnForWhere(QueryBuilder $query, $clause) { if (isset($clause['column'])) { - return ' ' . $query->prepareColumnName($clause['column']); + return ' ' . $query->resolveQualifier($query->prepareColumnName($clause['column'])); } } @@ -300,7 +301,7 @@ private function prepareValueForWhere(QueryBuilder $query, $clause) { $sql = ''; if (isset($clause['secondColumn'])) { - return ' ' . $clause['secondColumn']; + return ' ' . $query->resolveQualifier($clause['secondColumn']); } if (!isset($clause['value'])) { diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index 4f8c87a..6dd164a 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -841,15 +841,88 @@ public function join($table, $firstColumn, $operator = null, $secondColumn = nul $on[] = $this->prepareOn($reference, $firstColumn, $operator, $secondColumn, 'AND'); $this->joins[] = [ - 'table' => $tableSql, - 'alias' => $reference, - 'on' => $on, - 'type' => $type, + 'table' => $tableSql, + 'alias' => $reference, + 'on' => $on, + 'type' => $type, + 'raw' => $rawTable, + 'prefixed' => $prefixedTable, + 'userAlias' => $alias, ]; return $this; } + /** + * Maps each unprefixed table name this query knows about (the model's own + * table plus every non-aliased join) to its physical, prefixed name. + * Aliased joins are excluded: they are referenced by their alias, not the + * physical table, so their columns must not be rewritten. + * + * @return array + */ + public function getTableMap() + { + $map = [$this->_model->getTableWithoutPrefix() => $this->table]; + foreach ($this->joins as $join) { + if (isset($join['raw'], $join['prefixed']) && ($join['userAlias'] ?? null) === null) { + $map[$join['raw']] = $join['prefixed']; + } + } + + return $map; + } + + /** + * Table aliases in scope (from() alias + every join alias). A qualifier that + * matches an alias is never rewritten to a physical table name. + * + * @return array + */ + public function getTableAliases() + { + $aliases = []; + if (!\is_null($this->_from)) { + $aliases[] = $this->_from; + } + foreach ($this->joins as $join) { + if (!empty($join['userAlias'])) { + $aliases[] = $join['userAlias']; + } + } + + return $aliases; + } + + /** + * Rewrites a qualified column whose table part is an unprefixed name this + * query owns (`users.id` -> `` `wp_users`.id ``). Aliases win over the map, + * and unknown / already-physical qualifiers pass through unchanged. + * Idempotent: a physical qualifier is never a map key. + * + * @param mixed $column + * + * @return mixed + */ + public function resolveQualifier($column) + { + if (!\is_string($column) || strpos($column, '.') === false) { + return $column; + } + + $dot = strpos($column, '.'); + $left = trim(substr($column, 0, $dot), '`'); + $right = substr($column, $dot + 1); + + if (\in_array($left, $this->getTableAliases(), true)) { + return $column; + } + + $map = $this->getTableMap(); + + return isset($map[$left]) ? '`' . $map[$left] . '`.' . $right : $column; + } + /** * Sets left join * diff --git a/tests/QualifiedColumnPrefixTest.php b/tests/QualifiedColumnPrefixTest.php new file mode 100644 index 0000000..4af69bc --- /dev/null +++ b/tests/QualifiedColumnPrefixTest.php @@ -0,0 +1,95 @@ +where('users.status', 1)->toSql(); + $this->assertStringContainsString('`wp_users`.status', $sql); + } + + public function testSelectResolvesModelAndJoinedTable(): void + { + $sql = (new User())->join('posts', 'user_id', '=', 'id') + ->select('users.id', 'posts.title')->toSql(); + + $this->assertStringContainsString('`wp_users`.id', $sql); + $this->assertStringContainsString('`wp_posts`.title', $sql); + } + + public function testSelectResolvesRegardlessOfJoinOrder(): void + { + $sql = (new User())->select('posts.title') + ->join('posts', 'user_id', '=', 'id')->toSql(); + + $this->assertStringContainsString('`wp_posts`.title', $sql); + } + + public function testJoinOnResolvesBothSides(): void + { + $sql = (new User())->join('posts', 'posts.user_id', '=', 'users.id')->toSql(); + + $this->assertStringContainsString('`wp_posts`.user_id', $sql); + $this->assertStringContainsString('`wp_users`.id', $sql); + $this->assertStringNotContainsString(' users.id', $sql); + } + + public function testAlreadyPhysicalNameUntouched(): void + { + $sql = (new User())->where('wp_users.id', 5)->toSql(); + + $this->assertStringContainsString('wp_users.id', $sql); + $this->assertStringNotContainsString('wp_wp_users', $sql); + $this->assertStringNotContainsString('`wp_users`.id', $sql); + } + + public function testAliasShadowingModelNameWins(): void + { + $sql = (new User())->from('users')->where('users.id', 5)->toSql(); + + $this->assertStringContainsString(' users.id', $sql); + $this->assertStringNotContainsString('`wp_users`.id', $sql); + } + + public function testGroupByResolvesJoinedTable(): void + { + $sql = (new User())->join('posts', 'user_id', '=', 'id') + ->groupBy('posts.status')->toSql(); + + $this->assertStringContainsString('GROUP BY `wp_posts`.status', $sql); + } + + public function testOrderByResolvesJoinedTable(): void + { + $sql = (new User())->join('posts', 'user_id', '=', 'id') + ->orderBy('posts.title')->toSql(); + + $this->assertStringContainsString('ORDER BY `wp_posts`.title', $sql); + } + + public function testResolveQualifierIsIdempotent(): void + { + $qb = User::query(); + $once = $qb->resolveQualifier('users.id'); + $twice = $qb->resolveQualifier($once); + + $this->assertSame('`wp_users`.id', $once); + $this->assertSame($once, $twice); + } + + public function testResolveQualifierLeavesUnqualifiedColumnAlone(): void + { + $this->assertSame('id', User::query()->resolveQualifier('id')); + } +} diff --git a/tests/Query/GrammarTest.php b/tests/Query/GrammarTest.php index 4a9d2fc..68cf0f3 100644 --- a/tests/Query/GrammarTest.php +++ b/tests/Query/GrammarTest.php @@ -84,10 +84,11 @@ public function testJoinWithAlias(): void $this->assertStringContainsString('= p.id', $sql); } - public function testJoinWithDottedColumnsUntouched(): void + public function testJoinWithDottedColumnsResolvesKnownTables(): void { + // Unprefixed model/join table names in ON columns resolve to physical. $sql = (new User())->join('posts', 'posts.user_id', '=', 'users.id')->toSql(); - $this->assertStringContainsString('= users.id', $sql); + $this->assertStringContainsString('`wp_posts`.user_id = `wp_users`.id', $sql); } public function testGroupBy(): void diff --git a/tests/QueryFeaturesTest.php b/tests/QueryFeaturesTest.php index b0bb66a..56ffc97 100644 --- a/tests/QueryFeaturesTest.php +++ b/tests/QueryFeaturesTest.php @@ -43,8 +43,9 @@ public function testInnerJoinCompilesWithOnClause(): void $sql = (new User())->join('posts', 'posts.user_id', '=', 'users.id')->toSql(); $this->assertStringContainsString('INNER JOIN', $sql); - $this->assertStringContainsString('posts.user_id', $sql); - $this->assertStringContainsString('users.id', $sql); + // Unprefixed model/join table names in ON columns resolve to physical. + $this->assertStringContainsString('`wp_posts`.user_id', $sql); + $this->assertStringContainsString('`wp_users`.id', $sql); } public function testLeftJoinThenWhereKeepsBindingsInOrder(): void diff --git a/tests/SelectJoinAggregateEdgeTest.php b/tests/SelectJoinAggregateEdgeTest.php index 089d97a..7f46c2d 100644 --- a/tests/SelectJoinAggregateEdgeTest.php +++ b/tests/SelectJoinAggregateEdgeTest.php @@ -76,11 +76,12 @@ public function testRightFullCrossJoinKeywords(): void public function testOnAndOrOnAppendToSameJoin(): void { + // Unprefixed model/join table names in ON columns resolve to physical. $and = (new User())->join('posts', 'posts.user_id', '=', 'users.id')->on('posts.status', '=', 'users.state')->toSql(); - $this->assertStringContainsString('posts.user_id = users.id AND posts.status = users.state', $and); + $this->assertStringContainsString('`wp_posts`.user_id = `wp_users`.id AND `wp_posts`.status = `wp_users`.state', $and); $or = (new User())->join('posts', 'posts.user_id', '=', 'users.id')->orOn('posts.status', '=', 'users.state')->toSql(); - $this->assertStringContainsString('posts.user_id = users.id OR posts.status = users.state', $or); + $this->assertStringContainsString('`wp_posts`.user_id = `wp_users`.id OR `wp_posts`.status = `wp_users`.state', $or); } public function testJoinOnCustomPrefixModelQualifiesBaseColumnWithFullPrefix(): void From 39d5b60f17b37750b67943eb7b5f7127fe3fc994 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Thu, 2 Jul 2026 13:54:51 +0600 Subject: [PATCH 097/102] fix: address review findings on the query-builder fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Resolve joined-table qualifiers inside nested where groups: closure conditions and the soft-delete scope wrap now inherit the parent's join/from context, so a joined table's unprefixed name resolves inside a nested group exactly as at top level (the inherited joins feed only the table map; they never emit JOIN SQL for the nested builder). - aggregate(): stop upper-casing $function so a direct aggregate('avg', …) keeps its pre-existing SQL/alias case, and reset DISTINCT on the clone so distinct()->count() no longer emits a spurious SELECT DISTINCT. - Extract the bare-identifier guard into assertSafeAggregateFunction() and apply it in withAggregate() too, closing the same raw-$function interpolation surface there. Assisted-By: AI --- src/Concerns/QueriesRelationships.php | 2 ++ src/QueryBuilder.php | 44 +++++++++++++++++++++++---- tests/AggregateMethodsTest.php | 30 ++++++++++++++++++ tests/QualifiedColumnPrefixTest.php | 19 ++++++++++++ 4 files changed, 89 insertions(+), 6 deletions(-) diff --git a/src/Concerns/QueriesRelationships.php b/src/Concerns/QueriesRelationships.php index 4b5ed88..3cce9cd 100644 --- a/src/Concerns/QueriesRelationships.php +++ b/src/Concerns/QueriesRelationships.php @@ -144,6 +144,8 @@ public function withAggregate($relation, $column, $function) return $this; } + $this->assertSafeAggregateFunction($function); + if (empty($this->select)) { $this->select = ["`{$this->_model->getTable()}`.*"]; } diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index 6dd164a..966a308 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -282,6 +282,7 @@ public function getClauseList($type) // Wrap user conditions to prevent AND/OR precedence issues with the injected scope $nestedQuery = $this->newQuery(); $nestedQuery->where = $this->where; + $this->inheritQualifierContext($nestedQuery); return [ ['query' => $nestedQuery], @@ -923,6 +924,23 @@ public function resolveQualifier($column) return isset($map[$left]) ? '`' . $map[$left] . '`.' . $right : $column; } + /** + * Copies this query's join/from context onto a nested builder so its + * resolveQualifier() sees the same table map — a joined table's unprefixed + * name resolves inside a nested where group exactly as at top level. The + * joins never emit JOIN SQL for the nested builder (only its conditions are + * compiled), so this affects the map only. + * + * @return QueryBuilder + */ + private function inheritQualifierContext(QueryBuilder $query) + { + $query->joins = $this->joins; + $query->_from = $this->_from; + + return $query; + } + /** * Sets left join * @@ -1308,22 +1326,35 @@ public function sum($column) public function aggregate($function, $column) { - $function = strtoupper((string) $function); - // $function is interpolated straight into SQL; allow only a bare - // identifier so parens/spaces/semicolons can't smuggle in a payload. - if (!preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $function)) { - throw new RuntimeException('Invalid aggregate function name.'); - } + $this->assertSafeAggregateFunction($function); $query = $this->clone(); $query->select = []; $query->selectRaw = ['columns' => [], 'bindings' => []]; + $query->distinct = false; $preparedColumn = $column === '*' ? '*' : $query->prepareColumnName($column); $result = $query->selectRaw($function . '(' . $preparedColumn . ') as ' . $function)->exec(); return \is_array($result) && isset($result[0]->{$function}) ? $result[0]->{$function} : null; } + /** + * Guards an aggregate function name that is interpolated straight into SQL: + * only a bare identifier is allowed, so parens/spaces/semicolons cannot + * smuggle in a payload. Case is preserved (SQL function names are + * case-insensitive). + * + * @param mixed $function + * + * @return void + */ + private function assertSafeAggregateFunction($function) + { + if (!\is_string($function) || !preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $function)) { + throw new RuntimeException('Invalid aggregate function name.'); + } + } + public function delete() { $this->_method = self::DELETE; @@ -1601,6 +1632,7 @@ protected function prepareConditional($params, $bool = 'AND', $type = 'where') $conditions['bool'] = $bool; if ($params[0] instanceof Closure) { $nestedQuery = $this->newQuery()->queryFor($type); + $this->inheritQualifierContext($nestedQuery); \call_user_func($params[0], $nestedQuery); $conditions['query'] = $nestedQuery; if (isset($params[1])) { diff --git a/tests/AggregateMethodsTest.php b/tests/AggregateMethodsTest.php index f80e8b6..38462f6 100644 --- a/tests/AggregateMethodsTest.php +++ b/tests/AggregateMethodsTest.php @@ -58,4 +58,34 @@ public function testAggregateAllowsNonAllowlistedAggregate(): void $this->assertSame('a,b', User::query()->aggregate('GROUP_CONCAT', 'score')); } + + public function testAggregatePreservesFunctionNameCase(): void + { + $GLOBALS['wpdb']->resolver = function () { + return [(object) ['avg' => '3']]; + }; + + User::query()->aggregate('avg', 'score'); + + $this->assertStringContainsString('avg(', $GLOBALS['wpdb']->last_query); + $this->assertStringNotContainsString('AVG(', $GLOBALS['wpdb']->last_query); + } + + public function testDistinctDoesNotLeakIntoAggregate(): void + { + $GLOBALS['wpdb']->resolver = function () { + return [(object) ['COUNT' => '5']]; + }; + + User::query()->distinct()->count(); + + $this->assertStringNotContainsString('DISTINCT', $GLOBALS['wpdb']->last_query); + } + + public function testWithAggregateRejectsNonIdentifierFunctionName(): void + { + $this->expectException(RuntimeException::class); + + User::query()->withAggregate('posts', 'id', 'SUM(x); DROP TABLE users; --'); + } } diff --git a/tests/QualifiedColumnPrefixTest.php b/tests/QualifiedColumnPrefixTest.php index 4af69bc..1c92e94 100644 --- a/tests/QualifiedColumnPrefixTest.php +++ b/tests/QualifiedColumnPrefixTest.php @@ -2,6 +2,7 @@ namespace BitApps\WPDatabase\Tests; +use BitApps\WPDatabase\Tests\Fixtures\SoftPost; use BitApps\WPDatabase\Tests\Fixtures\User; use PHPUnit\Framework\TestCase; @@ -92,4 +93,22 @@ public function testResolveQualifierLeavesUnqualifiedColumnAlone(): void { $this->assertSame('id', User::query()->resolveQualifier('id')); } + + public function testJoinedTableResolvesInsideNestedClosure(): void + { + $sql = (new User())->join('posts', 'user_id', '=', 'id') + ->where(function ($q) { + $q->where('posts.status', 1); + })->toSql(); + + $this->assertStringContainsString('`wp_posts`.status', $sql); + } + + public function testJoinedTableResolvesUnderSoftDeleteScope(): void + { + $sql = (new SoftPost())->join('users', 'post_id', '=', 'id') + ->where('users.role', 'admin')->toSql(); + + $this->assertStringContainsString('`wp_users`.role', $sql); + } } From d9ac687e027239faf036494e9195a219bc073fd1 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Thu, 2 Jul 2026 13:55:56 +0600 Subject: [PATCH 098/102] docs: document distinct(), avg()/sum(), and qualified-column resolution Assisted-By: AI --- docs/usage.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/usage.md b/docs/usage.md index d3255ca..141884b 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -200,6 +200,7 @@ Contact::select('id', 'email')->get(); Contact::select(['id', 'email'])->get(); Contact::select(['id', 'title AS t'])->get(); // column alias — qualifies `title`, keeps AS `t` Contact::addSelect('phone')->get(); // add to existing select +Contact::select('id', 'email')->distinct()->get(); // SELECT DISTINCT Contact::selectRaw('COUNT(*) as total')->get(); // raw expression (NOT select()) Contact::selectRaw('SUM(amount) as amt', [])->get(); ``` @@ -276,7 +277,11 @@ Contact::query() Pass **unprefixed** table names — `join()` prepends the model's full table prefix (the same one the model's own table uses, including `wp_` for models with a custom -`$prefix`). Qualify the `ON` columns as `table.column`. +`$prefix`). Qualify columns as `table.column` using the **unprefixed** table name: +the builder resolves a qualifier that matches the model's own table or a joined +table to its physical, prefixed name in `select`, `where`/`having`, `ON`, `groupBy` +and `orderBy`. Already-prefixed names, table aliases, and unknown tables are left +untouched. ### Limit / offset / pagination @@ -301,8 +306,15 @@ $page = Contact::where('is_active', 1)->paginate($pageNo = 1, $perPage = 20); Contact::where('is_active', 1)->count(); // int — always (returns 0, not null, on no rows) Contact::max('score'); // mixed|null — null when the result set is empty Contact::min('score'); // mixed|null — null when the result set is empty +Contact::avg('score'); // mixed|null +Contact::sum('score'); // mixed|null +Contact::aggregate('GROUP_CONCAT', 'tag'); // any bare-identifier function ``` +> `aggregate()` accepts any SQL function whose name is a bare identifier +> (letters, digits, underscore); other input is rejected. It does not emit +> `COUNT(DISTINCT …)` — `distinct()` is ignored by `count()`/`paginate()`. + ### Inspecting the SQL ```php From d6a642c673d3f1fe80ac0e7c3066b908a594f5da Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Thu, 2 Jul 2026 15:49:21 +0600 Subject: [PATCH 099/102] refactor: simplify qualifier helpers - resolveQualifier(): compute the dot position once instead of scanning twice. - getTableMap()/getTableAliases(): drop the isset()/coalesce guards (join() always populates raw/prefixed/userAlias) and use symmetric null checks so the map and alias set stay complementary. - Replace inheritQualifierContext() with a newNestedQuery() factory that constructs the sub-builder and inherits join/from context in one call, naming the nested-builder intent and keeping newQuery() for the independent bulkInsert re-query. Assisted-By: AI --- src/QueryBuilder.php | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index 966a308..1155dce 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -280,9 +280,8 @@ public function getClauseList($type) } // Wrap user conditions to prevent AND/OR precedence issues with the injected scope - $nestedQuery = $this->newQuery(); + $nestedQuery = $this->newNestedQuery(); $nestedQuery->where = $this->where; - $this->inheritQualifierContext($nestedQuery); return [ ['query' => $nestedQuery], @@ -866,7 +865,7 @@ public function getTableMap() { $map = [$this->_model->getTableWithoutPrefix() => $this->table]; foreach ($this->joins as $join) { - if (isset($join['raw'], $join['prefixed']) && ($join['userAlias'] ?? null) === null) { + if ($join['userAlias'] === null) { $map[$join['raw']] = $join['prefixed']; } } @@ -887,7 +886,7 @@ public function getTableAliases() $aliases[] = $this->_from; } foreach ($this->joins as $join) { - if (!empty($join['userAlias'])) { + if ($join['userAlias'] !== null) { $aliases[] = $join['userAlias']; } } @@ -907,11 +906,15 @@ public function getTableAliases() */ public function resolveQualifier($column) { - if (!\is_string($column) || strpos($column, '.') === false) { + if (!\is_string($column)) { + return $column; + } + + $dot = strpos($column, '.'); + if ($dot === false) { return $column; } - $dot = strpos($column, '.'); $left = trim(substr($column, 0, $dot), '`'); $right = substr($column, $dot + 1); @@ -925,16 +928,17 @@ public function resolveQualifier($column) } /** - * Copies this query's join/from context onto a nested builder so its - * resolveQualifier() sees the same table map — a joined table's unprefixed - * name resolves inside a nested where group exactly as at top level. The - * joins never emit JOIN SQL for the nested builder (only its conditions are - * compiled), so this affects the map only. + * Creates a nested builder that compiles its conditions inside this query's + * table context: it shares the model (via newQuery()) plus this query's + * joins and from() alias, so resolveQualifier() inside a nested where group + * resolves joined-table qualifiers exactly as at top level. The joins stay + * inert — a nested builder compiles only its conditions, never JOIN SQL. * * @return QueryBuilder */ - private function inheritQualifierContext(QueryBuilder $query) + private function newNestedQuery() { + $query = $this->newQuery(); $query->joins = $this->joins; $query->_from = $this->_from; @@ -1631,8 +1635,7 @@ protected function prepareConditional($params, $bool = 'AND', $type = 'where') $conditions['bool'] = $bool; if ($params[0] instanceof Closure) { - $nestedQuery = $this->newQuery()->queryFor($type); - $this->inheritQualifierContext($nestedQuery); + $nestedQuery = $this->newNestedQuery()->queryFor($type); \call_user_func($params[0], $nestedQuery); $conditions['query'] = $nestedQuery; if (isset($params[1])) { From 39de2253e0df2c717bda5821210b2cf57d2d732f Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Sat, 4 Jul 2026 15:09:49 +0600 Subject: [PATCH 100/102] fix: robust join alias split and ON constant operands Address Gemini review on PR #17: - Split table/alias on \s+as\s+ (with trim + a 2-part limit) so extra spaces/tabs around AS no longer leak into the table name or alias. - prepareOn() qualifies the second operand only when it is a bare column identifier; constants, quoted values and function calls (10, 'active', NOW()) are left untouched instead of becoming invalid SQL like posts.10. Assisted-By: AI --- src/QueryBuilder.php | 7 +++++-- tests/Query/GrammarTest.php | 21 +++++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index 1155dce..85f82aa 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -832,7 +832,7 @@ public function orHaving(...$params) */ public function join($table, $firstColumn, $operator = null, $secondColumn = null, $type = 'INNER') { - $parts = preg_split('/ as /i', $table); + $parts = preg_split('/\s+as\s+/i', trim($table), 2); $rawTable = $parts[0]; $alias = isset($parts[1]) ? $parts[1] : null; $prefixedTable = $this->_model->getTablePrefix() . $rawTable; @@ -1711,7 +1711,10 @@ protected function prepareOn($table, $column, $operator, $secondColumn, $bool = $operator = '='; } - if (!\is_null($secondColumn) && strpos($secondColumn, '.') === false) { + // Qualify only a bare column identifier; leave constants, quoted values + // and function calls (10, 'active', NOW()) untouched, and dotted names + // that are already qualified. + if (!\is_null($secondColumn) && preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $secondColumn)) { $secondColumn = $table . '.' . $secondColumn; } diff --git a/tests/Query/GrammarTest.php b/tests/Query/GrammarTest.php index 68cf0f3..855e2df 100644 --- a/tests/Query/GrammarTest.php +++ b/tests/Query/GrammarTest.php @@ -91,6 +91,27 @@ public function testJoinWithDottedColumnsResolvesKnownTables(): void $this->assertStringContainsString('`wp_posts`.user_id = `wp_users`.id', $sql); } + public function testJoinAliasSplitToleratesExtraWhitespace(): void + { + $sql = (new User())->join('posts as p', 'user_id', '=', 'id')->toSql(); + $this->assertStringContainsString('INNER JOIN wp_posts as p ON', $sql); + $this->assertStringContainsString('= p.id', $sql); + } + + public function testJoinOnConstantSecondOperandNotPrefixed(): void + { + $sql = (new User())->join('posts', 'posts.owner_id', '=', '5')->toSql(); + $this->assertStringContainsString('= 5', $sql); + $this->assertStringNotContainsString('.5', $sql); + } + + public function testJoinOnFunctionSecondOperandNotPrefixed(): void + { + $sql = (new User())->join('posts', 'posts.created', '<', 'NOW()')->toSql(); + $this->assertStringContainsString('< NOW()', $sql); + $this->assertStringNotContainsString('.NOW', $sql); + } + public function testGroupBy(): void { $this->assertSame( From 1141376274a4623b529098f5bce2c10160265bea Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Sat, 4 Jul 2026 15:30:25 +0600 Subject: [PATCH 101/102] chore: exclude tests and test config from dist archive Add /tests, phpunit.xml and phpcs.xml to export-ignore so composer dist tarballs ship only the runtime package. Assisted-By: AI --- .gitattributes | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitattributes b/.gitattributes index 3ed6878..dd1f26f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,4 +5,7 @@ lefthook.yml export-ignore .php-cs-fixer.php export-ignore .gitattributes export-ignore -.gitignore export-ignore \ No newline at end of file +.gitignore export-ignore +/tests export-ignore +phpunit.xml export-ignore +phpcs.xml export-ignore \ No newline at end of file From 0bff4a4a1be4357b55cbf78a6e1ead4338d5e178 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Sat, 4 Jul 2026 15:37:57 +0600 Subject: [PATCH 102/102] chore: exclude docs from dist archive Ship the leanest runtime tarball; docs remain in the repo. Assisted-By: AI --- .gitattributes | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitattributes b/.gitattributes index dd1f26f..5582f82 100644 --- a/.gitattributes +++ b/.gitattributes @@ -7,5 +7,6 @@ lefthook.yml export-ignore .gitattributes export-ignore .gitignore export-ignore /tests export-ignore +/docs export-ignore phpunit.xml export-ignore phpcs.xml export-ignore \ No newline at end of file