From 19a75a2359815a297239aa42ba5b748d2e11266e Mon Sep 17 00:00:00 2001 From: Bit-Code-Developer Date: Sun, 16 Feb 2025 17:38:56 +0600 Subject: [PATCH 01/61] 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 02/61] 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 03/61] 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 04/61] 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 05/61] 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 06/61] 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 07/61] 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 08/61] 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 09/61] 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 10/61] 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 11/61] 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 12/61] 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 13/61] 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 14/61] 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 15/61] 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 16/61] 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 17/61] 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 18/61] 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 19/61] 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 20/61] 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 21/61] 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 22/61] 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 23/61] 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 24/61] 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 25/61] 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 26/61] 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 27/61] 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 28/61] 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 29/61] 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 30/61] 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 31/61] 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 32/61] 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 33/61] 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 34/61] 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 35/61] 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 36/61] 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 37/61] 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 38/61] 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 39/61] 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 40/61] 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 41/61] 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 42/61] 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 43/61] 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 44/61] 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 45/61] 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 46/61] 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 47/61] 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 48/61] 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 49/61] 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 50/61] 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 51/61] 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 52/61] 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 53/61] =?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 54/61] 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 55/61] 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 56/61] 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 57/61] 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 58/61] 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 59/61] 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 60/61] 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 61/61] 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/