From 8e81822c8538a708fbf563d7ab6934f451d81b03 Mon Sep 17 00:00:00 2001 From: Omar Barbosa Date: Sat, 5 Oct 2024 23:08:13 -0500 Subject: [PATCH 01/44] docs: update changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ff1217f..90c1d35d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [v0.4.0 (2024-09-27)](https://github.com/phenixphp/framework/compare/0.3.8...0.4.0) ### Added +- Integrate CORS middleware. ([#30](https://github.com/phenixphp/framework/pull/30)) +- Add basic form request. ([#31](https://github.com/phenixphp/framework/pull/31)) - Basic validation layer using form request. ([#32](https://github.com/phenixphp/framework/pull/32)) - Stream form parser. ([#33](https://github.com/phenixphp/framework/pull/33)) - Move validation layer to framework. ([#34](https://github.com/phenixphp/framework/pull/34)) From c5c32a697d01eb48b812f87456c156b838e72b3a Mon Sep 17 00:00:00 2001 From: Omar Barbosa Date: Sat, 5 Oct 2024 23:09:01 -0500 Subject: [PATCH 02/44] refactor: change property visibility --- src/Configurations/Cors.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Configurations/Cors.php b/src/Configurations/Cors.php index 26cf4b0a..0c4c75d0 100644 --- a/src/Configurations/Cors.php +++ b/src/Configurations/Cors.php @@ -8,12 +8,12 @@ class Cors extends Configuration { - public readonly array|string $origins; - public readonly array $allowedMethods; - public readonly int $maxAge; - public readonly array $allowedHeaders; - public readonly array $exposableHeaders; - public readonly bool $allowCredentials; + protected array|string $origins; + protected array $allowedMethods; + protected int $maxAge; + protected array $allowedHeaders; + protected array $exposableHeaders; + protected bool $allowCredentials; public function __construct(array $config) { From 10f1e7a2cf6e865c96f029f2082e86b4fc81713b Mon Sep 17 00:00:00 2001 From: Omar Barbosa Date: Sun, 6 Oct 2024 16:15:54 -0500 Subject: [PATCH 03/44] feat: initial basic data modeling --- src/Contracts/Database/ModelProperty.php | 10 ++ src/Database/Constants/IdType.php | 11 +++ src/Database/Models/DatabaseModel.php | 93 +++++++++++++++++++ src/Database/Models/DatabaseModelProperty.php | 55 +++++++++++ src/Database/Models/Properties/Column.php | 22 +++++ src/Database/Models/Properties/Id.php | 24 +++++ .../QueryBuilders/DatabaseQueryBuilder.php | 92 ++++++++++++++++++ src/Database/ORM/Ashes.php | 15 --- src/Database/ORM/Model.php | 15 --- .../Database/ModelPropertyException.php | 12 +++ tests/Feature/Database/DatabaseModelTest.php | 57 ++++++++++++ tests/Feature/Database/Models/User.php | 39 ++++++++ tests/Feature/Database/Models/UserQuery.php | 11 +++ 13 files changed, 426 insertions(+), 30 deletions(-) create mode 100644 src/Contracts/Database/ModelProperty.php create mode 100644 src/Database/Constants/IdType.php create mode 100644 src/Database/Models/DatabaseModel.php create mode 100644 src/Database/Models/DatabaseModelProperty.php create mode 100644 src/Database/Models/Properties/Column.php create mode 100644 src/Database/Models/Properties/Id.php create mode 100644 src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php delete mode 100644 src/Database/ORM/Ashes.php delete mode 100644 src/Database/ORM/Model.php create mode 100644 src/Exceptions/Database/ModelPropertyException.php create mode 100644 tests/Feature/Database/DatabaseModelTest.php create mode 100644 tests/Feature/Database/Models/User.php create mode 100644 tests/Feature/Database/Models/UserQuery.php diff --git a/src/Contracts/Database/ModelProperty.php b/src/Contracts/Database/ModelProperty.php new file mode 100644 index 00000000..a43a3a69 --- /dev/null +++ b/src/Contracts/Database/ModelProperty.php @@ -0,0 +1,10 @@ +|null + */ + private array|null $propertyBindings; + protected DatabaseQueryBuilder|null $queryBuilder; + + public function __construct() + { + $this->table = static::table(); + $this->propertyBindings = null; + $this->queryBuilder = null; + } + + public static function query(): DatabaseQueryBuilder + { + $queryBuilder = static::newQueryBuilder(); + $queryBuilder->setModel(new static()); + $queryBuilder->table(static::table()); + + return $queryBuilder; + } + + /** + * @return array + */ + public function getPropertyBindings(): array + { + return $this->propertyBindings ??= $this->buildPropertyBindings(); + } + + abstract protected static function table(): string; + + abstract protected static function newQueryBuilder(): DatabaseQueryBuilder; + + protected function buildPropertyBindings(): array + { + $reflection = new ReflectionObject($this); + + $bindings = []; + + foreach ($reflection->getProperties() as $property) { + $attributes = array_map(function (ReflectionAttribute $attr): object { + return $attr->newInstance(); + }, $property->getAttributes()); + + /** @var array $attributes */ + $attributes = array_filter($attributes, fn (object $attr) => $attr instanceof ModelProperty); + + if (empty($attributes)) { + continue; + } + + $attribute = array_shift($attributes); + $columnName = $attribute->getColumnName() ?? $property->getName(); + + $bindings[$columnName] = new DatabaseModelProperty( + $property->getName(), + (string) $property->getType(), + class_exists((string) $property->getType()), + $attribute, + $property->isInitialized($this) ? $property->getValue($this) : null + ); + } + + return $bindings; + } + + // Relationships + + // API: save, delete, update, updateOr, first, alone, firstOr, get, cursor, paginate + // Static API: Create, find, findOr + + // Model config feature +} diff --git a/src/Database/Models/DatabaseModelProperty.php b/src/Database/Models/DatabaseModelProperty.php new file mode 100644 index 00000000..1944d0d6 --- /dev/null +++ b/src/Database/Models/DatabaseModelProperty.php @@ -0,0 +1,55 @@ +value; + + return match ($this->type) { + Date::class => $this->resolveDate($value), + default => $this->resolveType($value), + }; + } + + public function isNullable(): bool + { + return str_starts_with($this->type, '?'); + } + + private function resolveDate(mixed $value): object|null + { + if (is_null($value) && $this->isNullable()) { + return null; + } + + return Date::parse($value); + } + + private function resolveType(mixed $value): object|null + { + if (is_null($value) && $this->isNullable()) { + return null; + } + + return new $this->type($value); + } +} diff --git a/src/Database/Models/Properties/Column.php b/src/Database/Models/Properties/Column.php new file mode 100644 index 00000000..abda5694 --- /dev/null +++ b/src/Database/Models/Properties/Column.php @@ -0,0 +1,22 @@ +name; + } +} diff --git a/src/Database/Models/Properties/Id.php b/src/Database/Models/Properties/Id.php new file mode 100644 index 00000000..332b2f89 --- /dev/null +++ b/src/Database/Models/Properties/Id.php @@ -0,0 +1,24 @@ +name; + } +} diff --git a/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php b/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php new file mode 100644 index 00000000..b0797581 --- /dev/null +++ b/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php @@ -0,0 +1,92 @@ +model)) { + $this->model = $model; + } + + return $this; + } + + public function table(string $table): static + { + if (! isset($this->table)) { + parent::table($table); + } + + return $this; + } + + public function from(Closure|string $table): static + { + if (! isset($this->table) && is_string($table)) { + parent::from($table); + } + + return $this; + } + + /** + * @return Collection + */ + public function get(): Collection + { + $this->action = Actions::SELECT; + + [$dml, $params] = $this->toSql(); + + $result = $this->connection->prepare($dml) + ->execute($params); + + $collection = new Collection(DatabaseModel::class); + $propertyBindings = $this->model->getPropertyBindings(); + + foreach ($result as $row) { + $collection->add($this->mapToModel($row, $propertyBindings)); + } + + return $collection; + } + + /** + * @param array $row + * @param array $propertyBindings + * @return DatabaseModel + */ + private function mapToModel(array $row, array $propertyBindings): DatabaseModel + { + $model = clone $this->model; + + foreach ($row as $columnName => $value) { + if (array_key_exists($columnName, $propertyBindings)) { + $property = $propertyBindings[$columnName]; + + $model->{$property->name} = $property->isInstantiable ? $property->resolveInstance($value) : $value; + } else { + throw new ModelPropertyException("Unknown database column {$columnName} for model " . $model::class); + } + } + + return $model; + } +} diff --git a/src/Database/ORM/Ashes.php b/src/Database/ORM/Ashes.php deleted file mode 100644 index d4af13d8..00000000 --- a/src/Database/ORM/Ashes.php +++ /dev/null @@ -1,15 +0,0 @@ -table; - } -} diff --git a/src/Database/ORM/Model.php b/src/Database/ORM/Model.php deleted file mode 100644 index d6e56663..00000000 --- a/src/Database/ORM/Model.php +++ /dev/null @@ -1,15 +0,0 @@ -app->stop(); +}); + +it('creates models with query builders successfully', function () { + $data = [ + [ + 'id' => 1, + 'name' => 'John Doe', + 'email' => 'john.doe@email.com', + 'created_at' => Date::now()->toDateTimeString(), + ], + ]; + + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->exactly(1)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result($data)), + ); + + $this->app->swap(Connections::default(), $connection); + + Route::post('/users', function (Request $request) use ($data): Response { + $users = User::query()->selectAllColumns()->get(); + + /** @var User $user */ + $user = $users->first(); + + expect($user->id)->toBe($data[0]['id']); + expect($user->name)->toBe($data[0]['name']); + expect($user->email)->toBe($data[0]['email']); + expect($user->createdAt)->toBeInstanceOf(Date::class); + + return response()->plain('Ok'); + }); + + $this->app->run(); + + $this->post('/users', $data) + ->assertOk(); +}); diff --git a/tests/Feature/Database/Models/User.php b/tests/Feature/Database/Models/User.php new file mode 100644 index 00000000..06c22158 --- /dev/null +++ b/tests/Feature/Database/Models/User.php @@ -0,0 +1,39 @@ + Date: Sun, 6 Oct 2024 16:40:02 -0500 Subject: [PATCH 04/44] feat: add model collection class --- .../Models/Collections/DatabaseModelCollection.php | 11 +++++++++++ src/Database/Models/DatabaseModel.php | 6 ++++++ .../Models/QueryBuilders/DatabaseQueryBuilder.php | 2 +- tests/Feature/Database/DatabaseModelTest.php | 7 ++++++- tests/Feature/Database/Models/User.php | 2 +- 5 files changed, 25 insertions(+), 3 deletions(-) create mode 100644 src/Database/Models/Collections/DatabaseModelCollection.php diff --git a/src/Database/Models/Collections/DatabaseModelCollection.php b/src/Database/Models/Collections/DatabaseModelCollection.php new file mode 100644 index 00000000..d4e11560 --- /dev/null +++ b/src/Database/Models/Collections/DatabaseModelCollection.php @@ -0,0 +1,11 @@ +propertyBindings ??= $this->buildPropertyBindings(); } + public function newCollection(): DatabaseModelCollection + { + return new DatabaseModelCollection($this::class); + } + abstract protected static function table(): string; abstract protected static function newQueryBuilder(): DatabaseQueryBuilder; diff --git a/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php b/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php index b0797581..cb23ad8d 100644 --- a/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php +++ b/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php @@ -58,7 +58,7 @@ public function get(): Collection $result = $this->connection->prepare($dml) ->execute($params); - $collection = new Collection(DatabaseModel::class); + $collection = $this->model->newCollection(); $propertyBindings = $this->model->getPropertyBindings(); foreach ($result as $row) { diff --git a/tests/Feature/Database/DatabaseModelTest.php b/tests/Feature/Database/DatabaseModelTest.php index ed9c54d7..2d05b7bf 100644 --- a/tests/Feature/Database/DatabaseModelTest.php +++ b/tests/Feature/Database/DatabaseModelTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Phenix\Database\Constants\Connections; +use Phenix\Database\Models\Collections\DatabaseModelCollection; use Phenix\Facades\Route; use Phenix\Http\Request; use Phenix\Http\Response; @@ -39,6 +40,9 @@ Route::post('/users', function (Request $request) use ($data): Response { $users = User::query()->selectAllColumns()->get(); + expect($users)->toBeInstanceOf(DatabaseModelCollection::class); + expect($users->first())->toBeInstanceOf(User::class); + /** @var User $user */ $user = $users->first(); @@ -46,8 +50,9 @@ expect($user->name)->toBe($data[0]['name']); expect($user->email)->toBe($data[0]['email']); expect($user->createdAt)->toBeInstanceOf(Date::class); + expect($user->updatedAt)->toBeNull(); - return response()->plain('Ok'); + return response()->json($users); }); $this->app->run(); diff --git a/tests/Feature/Database/Models/User.php b/tests/Feature/Database/Models/User.php index 06c22158..dd24df1d 100644 --- a/tests/Feature/Database/Models/User.php +++ b/tests/Feature/Database/Models/User.php @@ -25,7 +25,7 @@ class User extends DatabaseModel public Date $createdAt; #[Column(name: 'updated_at')] - public Date|null $updatedAt; + public Date|null $updatedAt = null; public static function table(): string { From 827faa6ac127526826fb7f6e86dda0b643f2ab64 Mon Sep 17 00:00:00 2001 From: Omar Barbosa Date: Mon, 14 Oct 2024 11:27:07 -0500 Subject: [PATCH 05/44] feat: load belongsTo relationship --- .../{ModelProperty.php => ModelAttribute.php} | 2 +- src/Database/Models/Attributes/BelongsTo.php | 17 ++ .../{Properties => Attributes}/Column.php | 7 +- src/Database/Models/Attributes/ForeignKey.php | 12 + .../Models/{Properties => Attributes}/Id.php | 10 +- .../Models/Attributes/ModelAttribute.php | 11 + src/Database/Models/Collection.php | 16 ++ .../Collections/DatabaseModelCollection.php | 11 - src/Database/Models/DatabaseModel.php | 131 ++++++++-- src/Database/Models/DatabaseModelProperty.php | 55 ---- .../Models/Properties/BelongsToProperty.php | 21 ++ .../Models/Properties/ModelProperty.php | 85 +++++++ .../QueryBuilders/DatabaseQueryBuilder.php | 240 ++++++++++++++++-- .../Relationships/BelongsToRelationship.php | 26 ++ .../Models/Relationships/Relationship.php | 12 + .../Relationships/RelationshipFactory.php | 20 ++ src/Util/Arr.php | 19 ++ tests/Feature/Database/DatabaseModelTest.php | 71 +++++- tests/Feature/Database/Models/Post.php | 47 ++++ tests/Feature/Database/Models/PostQuery.php | 11 + tests/Feature/Database/Models/User.php | 4 +- 21 files changed, 701 insertions(+), 127 deletions(-) rename src/Contracts/Database/{ModelProperty.php => ModelAttribute.php} (83%) create mode 100644 src/Database/Models/Attributes/BelongsTo.php rename src/Database/Models/{Properties => Attributes}/Column.php (56%) create mode 100644 src/Database/Models/Attributes/ForeignKey.php rename src/Database/Models/{Properties => Attributes}/Id.php (54%) create mode 100644 src/Database/Models/Attributes/ModelAttribute.php create mode 100644 src/Database/Models/Collection.php delete mode 100644 src/Database/Models/Collections/DatabaseModelCollection.php delete mode 100644 src/Database/Models/DatabaseModelProperty.php create mode 100644 src/Database/Models/Properties/BelongsToProperty.php create mode 100644 src/Database/Models/Properties/ModelProperty.php create mode 100644 src/Database/Models/Relationships/BelongsToRelationship.php create mode 100644 src/Database/Models/Relationships/Relationship.php create mode 100644 src/Database/Models/Relationships/RelationshipFactory.php create mode 100644 tests/Feature/Database/Models/Post.php create mode 100644 tests/Feature/Database/Models/PostQuery.php diff --git a/src/Contracts/Database/ModelProperty.php b/src/Contracts/Database/ModelAttribute.php similarity index 83% rename from src/Contracts/Database/ModelProperty.php rename to src/Contracts/Database/ModelAttribute.php index a43a3a69..ef8b59a0 100644 --- a/src/Contracts/Database/ModelProperty.php +++ b/src/Contracts/Database/ModelAttribute.php @@ -4,7 +4,7 @@ namespace Phenix\Contracts\Database; -interface ModelProperty +interface ModelAttribute { public function getColumnName(): string|null; } diff --git a/src/Database/Models/Attributes/BelongsTo.php b/src/Database/Models/Attributes/BelongsTo.php new file mode 100644 index 00000000..429be3ab --- /dev/null +++ b/src/Database/Models/Attributes/BelongsTo.php @@ -0,0 +1,17 @@ +name; - } } diff --git a/src/Database/Models/Attributes/ModelAttribute.php b/src/Database/Models/Attributes/ModelAttribute.php new file mode 100644 index 00000000..a422279c --- /dev/null +++ b/src/Database/Models/Attributes/ModelAttribute.php @@ -0,0 +1,11 @@ +map(fn (DatabaseModel $model) => $model->getKey()) + ->toArray(); + } +} diff --git a/src/Database/Models/Collections/DatabaseModelCollection.php b/src/Database/Models/Collections/DatabaseModelCollection.php deleted file mode 100644 index d4e11560..00000000 --- a/src/Database/Models/Collections/DatabaseModelCollection.php +++ /dev/null @@ -1,11 +0,0 @@ -|null + * @var array|null */ - private array|null $propertyBindings; + protected array|null $propertyBindings = null; + protected array|null $relationshipBindings = null; protected DatabaseQueryBuilder|null $queryBuilder; public function __construct() { $this->table = static::table(); - $this->propertyBindings = null; $this->queryBuilder = null; + $this->propertyBindings = null; + $this->relationshipBindings = null; } + abstract protected static function table(): string; + + abstract protected static function newQueryBuilder(): DatabaseQueryBuilder; + public static function query(): DatabaseQueryBuilder { $queryBuilder = static::newQueryBuilder(); $queryBuilder->setModel(new static()); - $queryBuilder->table(static::table()); return $queryBuilder; } /** - * @return array + * @return array */ public function getPropertyBindings(): array { return $this->propertyBindings ??= $this->buildPropertyBindings(); } - public function newCollection(): DatabaseModelCollection + /** + * @return array> + */ + public function getRelationshipBindings() { - return new DatabaseModelCollection($this::class); + return $this->relationshipBindings ??= $this->buildRelationshipBindings(); } - abstract protected static function table(): string; + public function newCollection(): Collection + { + return new Collection($this::class); + } - abstract protected static function newQueryBuilder(): DatabaseQueryBuilder; + public function getTable(): string + { + return $this->table; + } + + public function getKey(): string|int + { + /** @var ModelProperty $key */ + $key = Arr::first($this->propertyBindings, function (ModelProperty $property): bool { + return $property->getAttribute() instanceof Id; + }); + + return $this->{$key->getName()}; + } + + public function toArray(): array + { + $data = []; + + foreach ($this->propertyBindings as $property) { + $value = $this->{$property->getName()}; + + if ($value instanceof Arrayable) { + $value = $value->toArray(); + } elseif ($value instanceof Date) { + $value = $value->toIso8601String(); + } + + $data[$property->getName()] = $value; + } + + return $data; + } + + public function toJson(): string + { + return json_encode($this->toArray()); + } protected function buildPropertyBindings(): array { @@ -68,8 +124,8 @@ protected function buildPropertyBindings(): array return $attr->newInstance(); }, $property->getAttributes()); - /** @var array $attributes */ - $attributes = array_filter($attributes, fn (object $attr) => $attr instanceof ModelProperty); + /** @var array $attributes */ + $attributes = array_filter($attributes, fn (object $attr) => $attr instanceof ModelAttribute); if (empty($attributes)) { continue; @@ -78,18 +134,49 @@ protected function buildPropertyBindings(): array $attribute = array_shift($attributes); $columnName = $attribute->getColumnName() ?? $property->getName(); - $bindings[$columnName] = new DatabaseModelProperty( - $property->getName(), - (string) $property->getType(), - class_exists((string) $property->getType()), - $attribute, - $property->isInitialized($this) ? $property->getValue($this) : null - ); + $bindings[$columnName] = $this->buildModelProperty($attribute, $property); } return $bindings; } + protected function buildRelationshipBindings(): array + { + $relationships = []; + + foreach ($this->getPropertyBindings() as $property) { + if ($property instanceof BelongsToProperty) { + $foreignKey = Arr::first($this->getPropertyBindings(), function (ModelProperty $modelProperty) use ($property): bool { + return $property->getAttribute()->foreignKey === $modelProperty->getName(); + }); + + if (! $foreignKey) { + throw new ModelPropertyException("Foreign key not found for {$property->getName()} relationship."); + } + + $relationships[$property->getName()] = [$property, $foreignKey]; + } + } + + return $relationships; + } + + protected function buildModelProperty(ModelAttribute $attribute, ReflectionProperty $property): ModelProperty + { + $arguments = [ + $property->getName(), + (string) $property->getType(), + class_exists((string) $property->getType()), + $attribute, + $property->isInitialized($this) ? $property->getValue($this) : null, + ]; + + return match($attribute::class) { + BelongsTo::class => new BelongsToProperty(...$arguments), + default => new ModelProperty(...$arguments), + }; + } + // Relationships // API: save, delete, update, updateOr, first, alone, firstOr, get, cursor, paginate diff --git a/src/Database/Models/DatabaseModelProperty.php b/src/Database/Models/DatabaseModelProperty.php deleted file mode 100644 index 1944d0d6..00000000 --- a/src/Database/Models/DatabaseModelProperty.php +++ /dev/null @@ -1,55 +0,0 @@ -value; - - return match ($this->type) { - Date::class => $this->resolveDate($value), - default => $this->resolveType($value), - }; - } - - public function isNullable(): bool - { - return str_starts_with($this->type, '?'); - } - - private function resolveDate(mixed $value): object|null - { - if (is_null($value) && $this->isNullable()) { - return null; - } - - return Date::parse($value); - } - - private function resolveType(mixed $value): object|null - { - if (is_null($value) && $this->isNullable()) { - return null; - } - - return new $this->type($value); - } -} diff --git a/src/Database/Models/Properties/BelongsToProperty.php b/src/Database/Models/Properties/BelongsToProperty.php new file mode 100644 index 00000000..5d999cad --- /dev/null +++ b/src/Database/Models/Properties/BelongsToProperty.php @@ -0,0 +1,21 @@ +attribute; + } + + public function query(): DatabaseQueryBuilder + { + return $this->type::query(); + } +} diff --git a/src/Database/Models/Properties/ModelProperty.php b/src/Database/Models/Properties/ModelProperty.php new file mode 100644 index 00000000..190d51b8 --- /dev/null +++ b/src/Database/Models/Properties/ModelProperty.php @@ -0,0 +1,85 @@ +value; + + return match ($this->type) { + Date::class => $this->resolveDate($value), + default => $this->resolveType($value), + }; + } + + public function isNullable(): bool + { + return str_starts_with($this->type, '?'); + } + + public function getName(): string + { + return $this->name; + } + + public function getColumnName(): string + { + return $this->attribute->getColumnName() ?? $this->name; + } + + public function getType(): string + { + return $this->type; + } + + public function isInstantiable(): bool + { + return $this->isInstantiable; + } + + public function getAttribute(): Column + { + return $this->attribute; + } + + public function getValue(): mixed + { + return $this->value; + } + + protected function resolveDate(mixed $value): object|null + { + if (is_null($value) && $this->isNullable()) { + return null; + } + + return Date::parse($value); + } + + protected function resolveType(mixed $value): object|null + { + if (is_null($value) && $this->isNullable()) { + return null; + } + + return new $this->type($value); + } +} diff --git a/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php b/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php index cb23ad8d..b84c7358 100644 --- a/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php +++ b/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php @@ -4,43 +4,196 @@ namespace Phenix\Database\Models\QueryBuilders; -use Closure; -use Phenix\Data\Collection; +use Amp\Sql\Common\SqlCommonConnectionPool; +use Amp\Sql\SqlQueryError; +use Amp\Sql\SqlTransactionError; +use League\Uri\Components\Query; +use League\Uri\Http; +use Phenix\App; +use Phenix\Database\Concerns\Query\BuildsQuery; +use Phenix\Database\Concerns\Query\HasJoinClause; use Phenix\Database\Constants\Actions; +use Phenix\Database\Constants\Connections; +use Phenix\Database\Models\Collection; use Phenix\Database\Models\DatabaseModel; -use Phenix\Database\Models\DatabaseModelProperty; -use Phenix\Database\QueryBuilder; +use Phenix\Database\Models\Properties\BelongsToProperty; +use Phenix\Database\Models\Properties\ModelProperty; +use Phenix\Database\Paginator; +use Phenix\Database\QueryBase; use Phenix\Exceptions\Database\ModelPropertyException; use function array_key_exists; use function is_string; -class DatabaseQueryBuilder extends QueryBuilder +class DatabaseQueryBuilder extends QueryBase { + use BuildsQuery { + table as protected; + from as protected; + insert as protected insertRows; + insertOrIgnore as protected insertOrIgnoreRows; + upsert as protected upsertRows; + insertFrom as protected insertFromRows; + update as protected updateRow; + delete as protected deleteRows; + count as protected countRows; + exists as protected existsRows; + doesntExist as protected doesntExistRows; + } + use HasJoinClause; + protected DatabaseModel $model; - public function setModel(DatabaseModel $model): self + /** + * @var array> + */ + protected array $relationships; + + protected SqlCommonConnectionPool $connection; + + public function __construct() { - if (! isset($this->model)) { - $this->model = $model; + parent::__construct(); + + $this->relationships = []; + $this->connection = App::make(Connections::default()); + } + + public function connection(SqlCommonConnectionPool|string $connection): self + { + if (is_string($connection)) { + $connection = App::make(Connections::name($connection)); } + $this->connection = $connection; + return $this; } - public function table(string $table): static + public function paginate(Http $uri, int $defaultPage = 1, int $defaultPerPage = 15): Paginator + { + $this->action = Actions::SELECT; + + $query = Query::fromUri($uri); + + $currentPage = filter_var($query->get('page') ?? $defaultPage, FILTER_SANITIZE_NUMBER_INT); + $currentPage = $currentPage === false ? $defaultPage : $currentPage; + + $perPage = filter_var($query->get('per_page') ?? $defaultPerPage, FILTER_SANITIZE_NUMBER_INT); + $perPage = $perPage === false ? $defaultPerPage : $perPage; + + $total = (new self())->connection($this->connection) + ->from($this->table) + ->count(); + + $data = $this->page((int) $currentPage, (int) $perPage)->get(); + + return new Paginator($uri, $data, (int) $total, (int) $currentPage, (int) $perPage); + } + + public function count(string $column = '*'): int { - if (! isset($this->table)) { - parent::table($table); + $this->action = Actions::SELECT; + + $this->countRows($column); + + [$dml, $params] = $this->toSql(); + + /** @var array $count */ + $count = $this->connection + ->prepare($dml) + ->execute($params) + ->fetchRow(); + + return array_values($count)[0]; + } + + public function insert(array $data): bool + { + [$dml, $params] = $this->insertRows($data)->toSql(); + + try { + $this->connection->prepare($dml)->execute($params); + + return true; + } catch (SqlQueryError|SqlTransactionError) { + return false; } + } + + public function exists(): bool + { + $this->action = Actions::EXISTS; + + $this->existsRows(); + + [$dml, $params] = $this->toSql(); + + $results = $this->connection->prepare($dml)->execute($params)->fetchRow(); + + return (bool) array_values($results)[0]; + } + + public function doesntExist(): bool + { + return ! $this->exists(); + } + + public function update(array $values): bool + { + $this->updateRow($values); + + [$dml, $params] = $this->toSql(); + + try { + $this->connection->prepare($dml)->execute($params); + + return true; + } catch (SqlQueryError|SqlTransactionError) { + return false; + } + } + + public function delete(): bool + { + $this->deleteRows(); + + [$dml, $params] = $this->toSql(); + + try { + $this->connection->prepare($dml)->execute($params); + + return true; + } catch (SqlQueryError|SqlTransactionError) { + return false; + } + } + + public function setModel(DatabaseModel $model): self + { + if (! isset($this->model)) { + $this->model = $model; + } + + $this->table = $this->model->getTable(); return $this; } - public function from(Closure|string $table): static + public function with(array|string $relationships): self { - if (! isset($this->table) && is_string($table)) { - parent::from($table); + $relationships = (array) $relationships; + + $modelRelationships = $this->model->getRelationshipBindings(); + + foreach ($relationships as $relationshipName) { + $relationship = $modelRelationships[$relationshipName] ?? null; + + if (! $relationship) { + throw new ModelPropertyException("Undefined relationship {$relationshipName} for " . $this->model::class); + } + + $this->relationships[] = $relationship; } return $this; @@ -59,34 +212,81 @@ public function get(): Collection ->execute($params); $collection = $this->model->newCollection(); - $propertyBindings = $this->model->getPropertyBindings(); foreach ($result as $row) { - $collection->add($this->mapToModel($row, $propertyBindings)); + $collection->add($this->mapToModel($row)); } + $this->resolveRelationships($collection); + return $collection; } + public function first(): DatabaseModel + { + $this->action = Actions::SELECT; + + $this->limit(1); + + $record = $this->get()->first(); + + return $record; + } + /** * @param array $row - * @param array $propertyBindings * @return DatabaseModel */ - private function mapToModel(array $row, array $propertyBindings): DatabaseModel + protected function mapToModel(array $row): DatabaseModel { + /** @var array $propertyBindings */ + $propertyBindings = $this->model->getPropertyBindings(); + $model = clone $this->model; foreach ($row as $columnName => $value) { if (array_key_exists($columnName, $propertyBindings)) { $property = $propertyBindings[$columnName]; - $model->{$property->name} = $property->isInstantiable ? $property->resolveInstance($value) : $value; + $model->{$property->getName()} = $property->isInstantiable() ? $property->resolveInstance($value) : $value; } else { - throw new ModelPropertyException("Unknown database column {$columnName} for model " . $model::class); + throw new ModelPropertyException("Unknown column '{$columnName}' for model " . $model::class); } } return $model; } + + protected function resolveRelationships(Collection $collection): void + { + foreach ($this->relationships as $relationshipBinding) { + $relationship = array_shift($relationshipBinding); + + if ($relationship instanceof BelongsToProperty) { + $this->resolveBelongsToRelationship(...[$collection, $relationship, ...$relationshipBinding]); + } + } + } + + protected function resolveBelongsToRelationship( + Collection $models, + BelongsToProperty $belongsToProperty, + ModelProperty $foreignKeyProperty + ): void { + /** @var Collection $records */ + $records = $belongsToProperty->getType()::query() + ->selectAllColumns() + ->whereIn($foreignKeyProperty->getAttribute()->getColumnName(), $models->modelKeys()) + ->get(); + + $models->map(function (DatabaseModel $model) use ($records, $belongsToProperty): DatabaseModel { + foreach ($records as $record) { + if ($record->getKey() === $model->getKey()) { + $model->{$belongsToProperty->getName()} = $record; + } + } + + return $model; + }); + } } diff --git a/src/Database/Models/Relationships/BelongsToRelationship.php b/src/Database/Models/Relationships/BelongsToRelationship.php new file mode 100644 index 00000000..d48bc391 --- /dev/null +++ b/src/Database/Models/Relationships/BelongsToRelationship.php @@ -0,0 +1,26 @@ +foreignKey = $properties[$property->attribute->foreignKey]; + } + + public static function make(ModelProperty $property, array $properties): static + { + return new static($property, $properties); + } +} diff --git a/src/Database/Models/Relationships/Relationship.php b/src/Database/Models/Relationships/Relationship.php new file mode 100644 index 00000000..762cb2dc --- /dev/null +++ b/src/Database/Models/Relationships/Relationship.php @@ -0,0 +1,12 @@ + BelongsToRelationship::make($property, $properties), + default => throw new InvalidArgumentException('Unknown relationship type ' . $property::class) + }; + } +} diff --git a/src/Util/Arr.php b/src/Util/Arr.php index 09058877..81f3f57f 100644 --- a/src/Util/Arr.php +++ b/src/Util/Arr.php @@ -48,4 +48,23 @@ public static function every(array $definition, Closure $closure): bool return true; } + + public static function first(array $data, Closure|null $closure = null, mixed $default = null): mixed + { + if ($closure) { + foreach ($data as $key => $value) { + if ($closure($value, $key)) { + return $value; + } + } + + return $default; + } + + if (array_is_list($data)) { + return $data[0] ?? $default; + } + + return array_values($data)[0] ?? $default; + } } diff --git a/tests/Feature/Database/DatabaseModelTest.php b/tests/Feature/Database/DatabaseModelTest.php index 2d05b7bf..960ec7dd 100644 --- a/tests/Feature/Database/DatabaseModelTest.php +++ b/tests/Feature/Database/DatabaseModelTest.php @@ -3,16 +3,19 @@ declare(strict_types=1); use Phenix\Database\Constants\Connections; -use Phenix\Database\Models\Collections\DatabaseModelCollection; +use Phenix\Database\Models\Collection; use Phenix\Facades\Route; use Phenix\Http\Request; use Phenix\Http\Response; use Phenix\Util\Date; +use Tests\Feature\Database\Models\Post; use Tests\Feature\Database\Models\User; use Tests\Mocks\Database\MysqlConnectionPool; use Tests\Mocks\Database\Result; use Tests\Mocks\Database\Statement; +use function Pest\Faker\faker; + afterEach(function () { $this->app->stop(); }); @@ -37,10 +40,10 @@ $this->app->swap(Connections::default(), $connection); - Route::post('/users', function (Request $request) use ($data): Response { + Route::get('/users', function (Request $request) use ($data): Response { $users = User::query()->selectAllColumns()->get(); - expect($users)->toBeInstanceOf(DatabaseModelCollection::class); + expect($users)->toBeInstanceOf(Collection::class); expect($users->first())->toBeInstanceOf(User::class); /** @var User $user */ @@ -57,6 +60,66 @@ $this->app->run(); - $this->post('/users', $data) + $this->get('/users', $data) + ->assertOk(); +}); + +it('loads the relationship when the model belongs to a parent model', function () { + $userData = [ + 'id' => 1, + 'name' => 'John Doe', + 'email' => 'john.doe@email.com', + 'created_at' => Date::now()->toDateTimeString(), + ]; + + $userCollection[] = $userData; + + $postData = [ + 'id' => 1, + 'title' => 'PHP is great', + 'content' => faker()->sentence(), + 'user_id' => $userData['id'], + 'created_at' => Date::now()->toDateTimeString(), + ]; + + $postCollection[] = $postData; + + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->exactly(2)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result($postCollection)), + new Statement(new Result($userCollection)), + ); + + $this->app->swap(Connections::default(), $connection); + + Route::get('/posts/{post}', function (Request $request) use ($postData, $userData): Response { + /** @var Post $post */ + $post = Post::query()->selectAllColumns() + ->with('user') + ->first(); + + expect($post)->toBeInstanceOf(Post::class); + + expect($post->id)->toBe($postData['id']); + expect($post->title)->toBe($postData['title']); + expect($post->content)->toBe($postData['content']); + expect($post->createdAt)->toBeInstanceOf(Date::class); + expect($post->updatedAt)->toBeNull(); + + expect($post->user)->toBeInstanceOf(User::class); + + expect($post->user->id)->toBe($userData['id']); + expect($post->user->name)->toBe($userData['name']); + expect($post->user->email)->toBe($userData['email']); + + return response()->json($post); + }); + + $this->app->run(); + + $this->get('/posts/' . $postData['id']) ->assertOk(); }); diff --git a/tests/Feature/Database/Models/Post.php b/tests/Feature/Database/Models/Post.php new file mode 100644 index 00000000..82e29849 --- /dev/null +++ b/tests/Feature/Database/Models/Post.php @@ -0,0 +1,47 @@ + Date: Mon, 14 Oct 2024 12:02:46 -0500 Subject: [PATCH 06/44] feat: query builder method as optional in model class --- src/Database/Models/DatabaseModel.php | 7 +++++-- tests/Feature/Database/Models/Post.php | 6 ------ tests/Feature/Database/Models/PostQuery.php | 11 ----------- tests/Feature/Database/Models/User.php | 6 ------ tests/Feature/Database/Models/UserQuery.php | 11 ----------- 5 files changed, 5 insertions(+), 36 deletions(-) delete mode 100644 tests/Feature/Database/Models/PostQuery.php delete mode 100644 tests/Feature/Database/Models/UserQuery.php diff --git a/src/Database/Models/DatabaseModel.php b/src/Database/Models/DatabaseModel.php index aa14d2ac..d0f5a90e 100644 --- a/src/Database/Models/DatabaseModel.php +++ b/src/Database/Models/DatabaseModel.php @@ -43,8 +43,6 @@ public function __construct() abstract protected static function table(): string; - abstract protected static function newQueryBuilder(): DatabaseQueryBuilder; - public static function query(): DatabaseQueryBuilder { $queryBuilder = static::newQueryBuilder(); @@ -113,6 +111,11 @@ public function toJson(): string return json_encode($this->toArray()); } + protected static function newQueryBuilder(): DatabaseQueryBuilder + { + return new DatabaseQueryBuilder(); + } + protected function buildPropertyBindings(): array { $reflection = new ReflectionObject($this); diff --git a/tests/Feature/Database/Models/Post.php b/tests/Feature/Database/Models/Post.php index 82e29849..f3e9820e 100644 --- a/tests/Feature/Database/Models/Post.php +++ b/tests/Feature/Database/Models/Post.php @@ -9,7 +9,6 @@ use Phenix\Database\Models\Attributes\ForeignKey; use Phenix\Database\Models\Attributes\Id; use Phenix\Database\Models\DatabaseModel; -use Phenix\Database\Models\QueryBuilders\DatabaseQueryBuilder; use Phenix\Util\Date; class Post extends DatabaseModel @@ -39,9 +38,4 @@ public static function table(): string { return 'posts'; } - - protected static function newQueryBuilder(): DatabaseQueryBuilder - { - return new PostQuery(); - } } diff --git a/tests/Feature/Database/Models/PostQuery.php b/tests/Feature/Database/Models/PostQuery.php deleted file mode 100644 index ddceecc9..00000000 --- a/tests/Feature/Database/Models/PostQuery.php +++ /dev/null @@ -1,11 +0,0 @@ - Date: Mon, 14 Oct 2024 12:08:17 -0500 Subject: [PATCH 07/44] feat: caching model key property --- src/Database/Models/DatabaseModel.php | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/Database/Models/DatabaseModel.php b/src/Database/Models/DatabaseModel.php index d0f5a90e..12f25bdb 100644 --- a/src/Database/Models/DatabaseModel.php +++ b/src/Database/Models/DatabaseModel.php @@ -26,6 +26,8 @@ abstract class DatabaseModel implements Arrayable { protected string $table; + protected ModelProperty|null $modelKey; + /** * @var array|null */ @@ -36,6 +38,7 @@ abstract class DatabaseModel implements Arrayable public function __construct() { $this->table = static::table(); + $this->modelKey = null; $this->queryBuilder = null; $this->propertyBindings = null; $this->relationshipBindings = null; @@ -79,12 +82,13 @@ public function getTable(): string public function getKey(): string|int { - /** @var ModelProperty $key */ - $key = Arr::first($this->propertyBindings, function (ModelProperty $property): bool { - return $property->getAttribute() instanceof Id; - }); + if (!$this->modelKey) { + $this->modelKey = Arr::first($this->getPropertyBindings(), function (ModelProperty $property): bool { + return $property->getAttribute() instanceof Id; + }); + } - return $this->{$key->getName()}; + return $this->{$this->modelKey->getName()}; } public function toArray(): array From 7e37140d7afe1ce975b1e17e2bb3b396b9e64a11 Mon Sep 17 00:00:00 2001 From: Omar Barbosa Date: Fri, 18 Oct 2024 16:14:52 -0500 Subject: [PATCH 08/44] feat: has many relationship --- src/Data/Collection.php | 13 +++ src/Database/Models/Attributes/BelongsTo.php | 2 +- src/Database/Models/Attributes/HasMany.php | 22 +++++ src/Database/Models/Collection.php | 21 ++++- src/Database/Models/DatabaseModel.php | 46 ++++++---- .../Models/Properties/HasManyProperty.php | 21 +++++ .../QueryBuilders/DatabaseQueryBuilder.php | 42 ++++++++- src/Util/Arr.php | 13 +++ tests/Feature/Database/DatabaseModelTest.php | 87 +++++++++++++++++-- tests/Feature/Database/Models/Product.php | 44 ++++++++++ tests/Feature/Database/Models/User.php | 5 ++ 11 files changed, 287 insertions(+), 29 deletions(-) create mode 100644 src/Database/Models/Attributes/HasMany.php create mode 100644 src/Database/Models/Properties/HasManyProperty.php create mode 100644 tests/Feature/Database/Models/Product.php diff --git a/src/Data/Collection.php b/src/Data/Collection.php index ab1870c6..a3a2200d 100644 --- a/src/Data/Collection.php +++ b/src/Data/Collection.php @@ -8,6 +8,8 @@ use Ramsey\Collection\Collection as GenericCollection; use SplFixedArray; +use function array_key_first; + class Collection extends GenericCollection implements Arrayable { public static function fromArray(array $data): self @@ -21,4 +23,15 @@ public static function fromArray(array $data): self return $collection; } + + public function first(): mixed + { + $firstIndex = array_key_first($this->data); + + if ($firstIndex === null) { + return null; + } + + return $this->data[$firstIndex]; + } } diff --git a/src/Database/Models/Attributes/BelongsTo.php b/src/Database/Models/Attributes/BelongsTo.php index 429be3ab..8d81aab4 100644 --- a/src/Database/Models/Attributes/BelongsTo.php +++ b/src/Database/Models/Attributes/BelongsTo.php @@ -10,7 +10,7 @@ readonly class BelongsTo extends Column { public function __construct( - public string $foreignKey, + public string $foreignProperty, public string|null $name = null, ) { } diff --git a/src/Database/Models/Attributes/HasMany.php b/src/Database/Models/Attributes/HasMany.php new file mode 100644 index 00000000..860da3a9 --- /dev/null +++ b/src/Database/Models/Attributes/HasMany.php @@ -0,0 +1,22 @@ +map(fn (DatabaseModel $model) => $model->getKey()) - ->toArray(); + return $this->reduce(function (array $carry, DatabaseModel $model): array { + $carry[] = $model->getKey(); + + return $carry; + }, []); + } + + public function map(callable $callback): self + { + return new self(get_class($this->first()), array_map($callback, $this->data)); + } + + public function toArray(): array + { + return $this->reduce(function (array $carry, DatabaseModel $model): array { + $carry[] = $model->toArray(); + + return $carry; + }, []); } } diff --git a/src/Database/Models/DatabaseModel.php b/src/Database/Models/DatabaseModel.php index 12f25bdb..859e5658 100644 --- a/src/Database/Models/DatabaseModel.php +++ b/src/Database/Models/DatabaseModel.php @@ -6,9 +6,11 @@ use Phenix\Contracts\Arrayable; use Phenix\Database\Models\Attributes\BelongsTo; +use Phenix\Database\Models\Attributes\HasMany; use Phenix\Database\Models\Attributes\Id; use Phenix\Database\Models\Attributes\ModelAttribute; use Phenix\Database\Models\Properties\BelongsToProperty; +use Phenix\Database\Models\Properties\HasManyProperty; use Phenix\Database\Models\Properties\ModelProperty; use Phenix\Database\Models\QueryBuilders\DatabaseQueryBuilder; use Phenix\Exceptions\Database\ModelPropertyException; @@ -96,15 +98,19 @@ public function toArray(): array $data = []; foreach ($this->propertyBindings as $property) { - $value = $this->{$property->getName()}; + $propertyName = $property->getName(); - if ($value instanceof Arrayable) { - $value = $value->toArray(); - } elseif ($value instanceof Date) { - $value = $value->toIso8601String(); - } + $value = isset($this->{$propertyName}) ? $this->{$propertyName} : null; + + if ($value || $property->isNullable()) { + if ($value instanceof Arrayable) { + $value = $value->toArray(); + } elseif ($value instanceof Date) { + $value = $value->toIso8601String(); + } - $data[$property->getName()] = $value; + $data[$propertyName] = $value; + } } return $data; @@ -153,15 +159,9 @@ protected function buildRelationshipBindings(): array foreach ($this->getPropertyBindings() as $property) { if ($property instanceof BelongsToProperty) { - $foreignKey = Arr::first($this->getPropertyBindings(), function (ModelProperty $modelProperty) use ($property): bool { - return $property->getAttribute()->foreignKey === $modelProperty->getName(); - }); - - if (! $foreignKey) { - throw new ModelPropertyException("Foreign key not found for {$property->getName()} relationship."); - } - - $relationships[$property->getName()] = [$property, $foreignKey]; + $relationships[$property->getName()] = $this->buildBelongsToRelationship($property); + } elseif ($property instanceof HasManyProperty) { + $relationships[$property->getName()] = Arr::wrap($property); } } @@ -180,10 +180,24 @@ class_exists((string) $property->getType()), return match($attribute::class) { BelongsTo::class => new BelongsToProperty(...$arguments), + HasMany::class => new HasManyProperty(...$arguments), default => new ModelProperty(...$arguments), }; } + protected function buildBelongsToRelationship(BelongsToProperty $property): array + { + $foreignKey = Arr::first($this->getPropertyBindings(), function (ModelProperty $modelProperty) use ($property): bool { + return $property->getAttribute()->foreignProperty === $modelProperty->getName(); + }); + + if (! $foreignKey) { + throw new ModelPropertyException("Foreign key not found for {$property->getName()} relationship."); + } + + return [$property, $foreignKey]; + } + // Relationships // API: save, delete, update, updateOr, first, alone, firstOr, get, cursor, paginate diff --git a/src/Database/Models/Properties/HasManyProperty.php b/src/Database/Models/Properties/HasManyProperty.php new file mode 100644 index 00000000..0f6b2257 --- /dev/null +++ b/src/Database/Models/Properties/HasManyProperty.php @@ -0,0 +1,21 @@ +attribute; + } + + public function query(): DatabaseQueryBuilder + { + return $this->getAttribute()->model::query(); + } +} diff --git a/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php b/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php index b84c7358..8caf676e 100644 --- a/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php +++ b/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php @@ -17,10 +17,12 @@ use Phenix\Database\Models\Collection; use Phenix\Database\Models\DatabaseModel; use Phenix\Database\Models\Properties\BelongsToProperty; +use Phenix\Database\Models\Properties\HasManyProperty; use Phenix\Database\Models\Properties\ModelProperty; use Phenix\Database\Paginator; use Phenix\Database\QueryBase; use Phenix\Exceptions\Database\ModelPropertyException; +use Phenix\Util\Arr; use function array_key_exists; use function is_string; @@ -217,7 +219,9 @@ public function get(): Collection $collection->add($this->mapToModel($row)); } - $this->resolveRelationships($collection); + if (!$collection->isEmpty()) { + $this->resolveRelationships($collection); + } return $collection; } @@ -264,6 +268,8 @@ protected function resolveRelationships(Collection $collection): void if ($relationship instanceof BelongsToProperty) { $this->resolveBelongsToRelationship(...[$collection, $relationship, ...$relationshipBinding]); + } elseif ($relationship instanceof HasManyProperty) { + $this->resolveHasManyRelationship($collection, $relationship); } } } @@ -274,7 +280,7 @@ protected function resolveBelongsToRelationship( ModelProperty $foreignKeyProperty ): void { /** @var Collection $records */ - $records = $belongsToProperty->getType()::query() + $records = $belongsToProperty->query() ->selectAllColumns() ->whereIn($foreignKeyProperty->getAttribute()->getColumnName(), $models->modelKeys()) ->get(); @@ -289,4 +295,36 @@ protected function resolveBelongsToRelationship( return $model; }); } + + /** + * @param Collection $models + * @param HasManyProperty $hasManyProperty + */ + protected function resolveHasManyRelationship( + Collection $models, + HasManyProperty $hasManyProperty, + ): void { + /** @var Collection $children */ + $children = $hasManyProperty->query() + ->selectAllColumns() + ->whereIn($hasManyProperty->getAttribute()->foreignKey, $models->modelKeys()) + ->get(); + + if (!$children->isEmpty()) { + /** @var ModelProperty $chaperoneProperty */ + $chaperoneProperty = Arr::first($children->first()->getPropertyBindings(), function (ModelProperty $property): bool { + return $this->model::class === $property->getType(); + }); + + $models->map(function (DatabaseModel $model) use ($children, $hasManyProperty, $chaperoneProperty): DatabaseModel { + $model->{$hasManyProperty->getName()} = $children->map(function (DatabaseModel $childModel) use ($model, $chaperoneProperty): DatabaseModel { + $childModel->{$chaperoneProperty->getName()} = clone $model; + + return $childModel; + }); + + return $model; + }); + } + } } diff --git a/src/Util/Arr.php b/src/Util/Arr.php index 81f3f57f..5527fffe 100644 --- a/src/Util/Arr.php +++ b/src/Util/Arr.php @@ -9,6 +9,7 @@ use function array_filter; use function implode; use function is_array; +use function is_null; class Arr extends Utility { @@ -67,4 +68,16 @@ public static function first(array $data, Closure|null $closure = null, mixed $d return array_values($data)[0] ?? $default; } + + /** + * Laravel team credits + */ + public static function wrap(mixed $value): array + { + if (is_null($value)) { + return []; + } + + return is_array($value) ? $value : [$value]; + } } diff --git a/tests/Feature/Database/DatabaseModelTest.php b/tests/Feature/Database/DatabaseModelTest.php index 960ec7dd..2f539346 100644 --- a/tests/Feature/Database/DatabaseModelTest.php +++ b/tests/Feature/Database/DatabaseModelTest.php @@ -2,19 +2,20 @@ declare(strict_types=1); -use Phenix\Database\Constants\Connections; -use Phenix\Database\Models\Collection; -use Phenix\Facades\Route; +use Phenix\Util\Date; use Phenix\Http\Request; +use Phenix\Facades\Route; use Phenix\Http\Response; -use Phenix\Util\Date; -use Tests\Feature\Database\Models\Post; -use Tests\Feature\Database\Models\User; -use Tests\Mocks\Database\MysqlConnectionPool; +use function Pest\Faker\faker; use Tests\Mocks\Database\Result; use Tests\Mocks\Database\Statement; +use Phenix\Database\Models\Collection; +use Tests\Feature\Database\Models\Post; +use Tests\Feature\Database\Models\User; +use Phenix\Database\Constants\Connections; -use function Pest\Faker\faker; +use Tests\Feature\Database\Models\Product; +use Tests\Mocks\Database\MysqlConnectionPool; afterEach(function () { $this->app->stop(); @@ -123,3 +124,73 @@ $this->get('/posts/' . $postData['id']) ->assertOk(); }); + +it('loads the relationship when the model has many child models', function () { + $userData = [ + 'id' => 1, + 'name' => 'John Doe', + 'email' => 'john.doe@email.com', + 'created_at' => Date::now()->toDateTimeString(), + ]; + + $userCollection[] = $userData; + + $productData = [ + 'id' => 1, + 'description' => 'Phenix shirt', + 'price' => 100, + 'stock' => 6, + 'user_id' => $userData['id'], + 'created_at' => Date::now()->toDateTimeString(), + ]; + + $productCollection[] = $productData; + + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->exactly(2)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result($userCollection)), + new Statement(new Result($productCollection)), + ); + + $this->app->swap(Connections::default(), $connection); + + Route::get('/users/{user}', function (Request $request) use ($userData, $productData): Response { + /** @var User $user */ + $user = User::query() + ->selectAllColumns() + ->whereEqual('id', $request->route('user')) + ->with('products') + ->first(); + + expect($user)->toBeInstanceOf(User::class); + + expect($user->id)->toBe($userData['id']); + expect($user->name)->toBe($userData['name']); + expect($user->email)->toBe($userData['email']); + + expect($user->products)->toBeInstanceOf(Collection::class); + expect($user->products->count())->toBe(1); + + /** @var Product $products */ + $product = $user->products->first(); + + expect($product->id)->toBe($productData['id']); + expect($product->description)->toBe($productData['description']); + expect($product->price)->toBe((float) $productData['price']); + expect($product->createdAt)->toBeInstanceOf(Date::class); + expect($product->userId)->toBe($userData['id']); + + expect($product->user)->toBeInstanceOf(User::class); + + return response()->json($user); + }); + + $this->app->run(); + + $this->get('/users/' . $userData['id']) + ->assertOk(); +}); + diff --git a/tests/Feature/Database/Models/Product.php b/tests/Feature/Database/Models/Product.php new file mode 100644 index 00000000..575e45ec --- /dev/null +++ b/tests/Feature/Database/Models/Product.php @@ -0,0 +1,44 @@ + Date: Fri, 18 Oct 2024 16:15:41 -0500 Subject: [PATCH 09/44] style: phpcs --- src/Database/Models/DatabaseModel.php | 2 +- .../QueryBuilders/DatabaseQueryBuilder.php | 4 ++-- tests/Feature/Database/DatabaseModelTest.php | 19 +++++++++---------- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/Database/Models/DatabaseModel.php b/src/Database/Models/DatabaseModel.php index 859e5658..effc842d 100644 --- a/src/Database/Models/DatabaseModel.php +++ b/src/Database/Models/DatabaseModel.php @@ -84,7 +84,7 @@ public function getTable(): string public function getKey(): string|int { - if (!$this->modelKey) { + if (! $this->modelKey) { $this->modelKey = Arr::first($this->getPropertyBindings(), function (ModelProperty $property): bool { return $property->getAttribute() instanceof Id; }); diff --git a/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php b/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php index 8caf676e..80ea0d79 100644 --- a/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php +++ b/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php @@ -219,7 +219,7 @@ public function get(): Collection $collection->add($this->mapToModel($row)); } - if (!$collection->isEmpty()) { + if (! $collection->isEmpty()) { $this->resolveRelationships($collection); } @@ -310,7 +310,7 @@ protected function resolveHasManyRelationship( ->whereIn($hasManyProperty->getAttribute()->foreignKey, $models->modelKeys()) ->get(); - if (!$children->isEmpty()) { + if (! $children->isEmpty()) { /** @var ModelProperty $chaperoneProperty */ $chaperoneProperty = Arr::first($children->first()->getPropertyBindings(), function (ModelProperty $property): bool { return $this->model::class === $property->getType(); diff --git a/tests/Feature/Database/DatabaseModelTest.php b/tests/Feature/Database/DatabaseModelTest.php index 2f539346..c867414c 100644 --- a/tests/Feature/Database/DatabaseModelTest.php +++ b/tests/Feature/Database/DatabaseModelTest.php @@ -2,20 +2,20 @@ declare(strict_types=1); -use Phenix\Util\Date; -use Phenix\Http\Request; +use Phenix\Database\Constants\Connections; +use Phenix\Database\Models\Collection; use Phenix\Facades\Route; +use Phenix\Http\Request; use Phenix\Http\Response; -use function Pest\Faker\faker; -use Tests\Mocks\Database\Result; -use Tests\Mocks\Database\Statement; -use Phenix\Database\Models\Collection; +use Phenix\Util\Date; use Tests\Feature\Database\Models\Post; -use Tests\Feature\Database\Models\User; -use Phenix\Database\Constants\Connections; - use Tests\Feature\Database\Models\Product; +use Tests\Feature\Database\Models\User; use Tests\Mocks\Database\MysqlConnectionPool; +use Tests\Mocks\Database\Result; +use Tests\Mocks\Database\Statement; + +use function Pest\Faker\faker; afterEach(function () { $this->app->stop(); @@ -193,4 +193,3 @@ $this->get('/users/' . $userData['id']) ->assertOk(); }); - From 61d1983c3501b89ec88acf486a449f6b15cc2282 Mon Sep 17 00:00:00 2001 From: Omar Barbosa Date: Wed, 23 Oct 2024 18:23:00 -0500 Subject: [PATCH 10/44] feat: create relationship builders --- src/Database/Models/DatabaseModel.php | 23 +++--- .../QueryBuilders/DatabaseQueryBuilder.php | 46 +++++------ .../Models/Relationships/BelongsTo.php | 33 ++++++++ .../Relationships/BelongsToRelationship.php | 26 ------ src/Database/Models/Relationships/HasMany.php | 26 ++++++ .../Models/Relationships/Relationship.php | 24 +++++- .../Relationships/RelationshipFactory.php | 20 ----- ...opertyException.php => ModelException.php} | 2 +- tests/Feature/Database/DatabaseModelTest.php | 82 ++++++++++++++++++- 9 files changed, 197 insertions(+), 85 deletions(-) create mode 100644 src/Database/Models/Relationships/BelongsTo.php delete mode 100644 src/Database/Models/Relationships/BelongsToRelationship.php create mode 100644 src/Database/Models/Relationships/HasMany.php delete mode 100644 src/Database/Models/Relationships/RelationshipFactory.php rename src/Exceptions/Database/{ModelPropertyException.php => ModelException.php} (68%) diff --git a/src/Database/Models/DatabaseModel.php b/src/Database/Models/DatabaseModel.php index effc842d..f2b0e973 100644 --- a/src/Database/Models/DatabaseModel.php +++ b/src/Database/Models/DatabaseModel.php @@ -5,15 +5,18 @@ namespace Phenix\Database\Models; use Phenix\Contracts\Arrayable; -use Phenix\Database\Models\Attributes\BelongsTo; -use Phenix\Database\Models\Attributes\HasMany; +use Phenix\Database\Models\Attributes\BelongsTo as BelongsToAttribute; +use Phenix\Database\Models\Attributes\HasMany as HasManyAttribute; use Phenix\Database\Models\Attributes\Id; use Phenix\Database\Models\Attributes\ModelAttribute; use Phenix\Database\Models\Properties\BelongsToProperty; use Phenix\Database\Models\Properties\HasManyProperty; use Phenix\Database\Models\Properties\ModelProperty; use Phenix\Database\Models\QueryBuilders\DatabaseQueryBuilder; -use Phenix\Exceptions\Database\ModelPropertyException; +use Phenix\Database\Models\Relationships\BelongsTo; +use Phenix\Database\Models\Relationships\HasMany; +use Phenix\Database\Models\Relationships\Relationship; +use Phenix\Exceptions\Database\ModelException; use Phenix\Util\Arr; use Phenix\Util\Date; use ReflectionAttribute; @@ -65,7 +68,7 @@ public function getPropertyBindings(): array } /** - * @return array> + * @return array> */ public function getRelationshipBindings() { @@ -161,7 +164,7 @@ protected function buildRelationshipBindings(): array if ($property instanceof BelongsToProperty) { $relationships[$property->getName()] = $this->buildBelongsToRelationship($property); } elseif ($property instanceof HasManyProperty) { - $relationships[$property->getName()] = Arr::wrap($property); + $relationships[$property->getName()] = new HasMany($property); } } @@ -179,23 +182,23 @@ class_exists((string) $property->getType()), ]; return match($attribute::class) { - BelongsTo::class => new BelongsToProperty(...$arguments), - HasMany::class => new HasManyProperty(...$arguments), + BelongsToAttribute::class => new BelongsToProperty(...$arguments), + HasManyAttribute::class => new HasManyProperty(...$arguments), default => new ModelProperty(...$arguments), }; } - protected function buildBelongsToRelationship(BelongsToProperty $property): array + protected function buildBelongsToRelationship(BelongsToProperty $property): BelongsTo { $foreignKey = Arr::first($this->getPropertyBindings(), function (ModelProperty $modelProperty) use ($property): bool { return $property->getAttribute()->foreignProperty === $modelProperty->getName(); }); if (! $foreignKey) { - throw new ModelPropertyException("Foreign key not found for {$property->getName()} relationship."); + throw new ModelException("Foreign key not found for {$property->getName()} relationship."); } - return [$property, $foreignKey]; + return new BelongsTo($property, $foreignKey); } // Relationships diff --git a/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php b/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php index 80ea0d79..baa1a3a3 100644 --- a/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php +++ b/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php @@ -16,12 +16,13 @@ use Phenix\Database\Constants\Connections; use Phenix\Database\Models\Collection; use Phenix\Database\Models\DatabaseModel; -use Phenix\Database\Models\Properties\BelongsToProperty; -use Phenix\Database\Models\Properties\HasManyProperty; use Phenix\Database\Models\Properties\ModelProperty; +use Phenix\Database\Models\Relationships\BelongsTo; +use Phenix\Database\Models\Relationships\HasMany; +use Phenix\Database\Models\Relationships\Relationship; use Phenix\Database\Paginator; use Phenix\Database\QueryBase; -use Phenix\Exceptions\Database\ModelPropertyException; +use Phenix\Exceptions\Database\ModelException; use Phenix\Util\Arr; use function array_key_exists; @@ -47,7 +48,7 @@ class DatabaseQueryBuilder extends QueryBase protected DatabaseModel $model; /** - * @var array> + * @var array */ protected array $relationships; @@ -192,7 +193,7 @@ public function with(array|string $relationships): self $relationship = $modelRelationships[$relationshipName] ?? null; if (! $relationship) { - throw new ModelPropertyException("Undefined relationship {$relationshipName} for " . $this->model::class); + throw new ModelException("Undefined relationship {$relationshipName} for " . $this->model::class); } $this->relationships[] = $relationship; @@ -254,7 +255,7 @@ protected function mapToModel(array $row): DatabaseModel $model->{$property->getName()} = $property->isInstantiable() ? $property->resolveInstance($value) : $value; } else { - throw new ModelPropertyException("Unknown column '{$columnName}' for model " . $model::class); + throw new ModelException("Unknown column '{$columnName}' for model " . $model::class); } } @@ -263,12 +264,10 @@ protected function mapToModel(array $row): DatabaseModel protected function resolveRelationships(Collection $collection): void { - foreach ($this->relationships as $relationshipBinding) { - $relationship = array_shift($relationshipBinding); - - if ($relationship instanceof BelongsToProperty) { - $this->resolveBelongsToRelationship(...[$collection, $relationship, ...$relationshipBinding]); - } elseif ($relationship instanceof HasManyProperty) { + foreach ($this->relationships as $relationship) { + if ($relationship instanceof BelongsTo) { + $this->resolveBelongsToRelationship(...[$collection, $relationship]); + } elseif ($relationship instanceof HasMany) { $this->resolveHasManyRelationship($collection, $relationship); } } @@ -276,19 +275,18 @@ protected function resolveRelationships(Collection $collection): void protected function resolveBelongsToRelationship( Collection $models, - BelongsToProperty $belongsToProperty, - ModelProperty $foreignKeyProperty + BelongsTo $relationship ): void { /** @var Collection $records */ - $records = $belongsToProperty->query() + $records = $relationship->query() ->selectAllColumns() - ->whereIn($foreignKeyProperty->getAttribute()->getColumnName(), $models->modelKeys()) + ->whereIn($relationship->getForeignKey()->getAttribute()->getColumnName(), $models->modelKeys()) ->get(); - $models->map(function (DatabaseModel $model) use ($records, $belongsToProperty): DatabaseModel { + $models->map(function (DatabaseModel $model) use ($records, $relationship): DatabaseModel { foreach ($records as $record) { if ($record->getKey() === $model->getKey()) { - $model->{$belongsToProperty->getName()} = $record; + $model->{$relationship->getProperty()->getName()} = $record; } } @@ -298,16 +296,16 @@ protected function resolveBelongsToRelationship( /** * @param Collection $models - * @param HasManyProperty $hasManyProperty + * @param HasMany $relationship */ protected function resolveHasManyRelationship( Collection $models, - HasManyProperty $hasManyProperty, + HasMany $relationship, ): void { /** @var Collection $children */ - $children = $hasManyProperty->query() + $children = $relationship->query() ->selectAllColumns() - ->whereIn($hasManyProperty->getAttribute()->foreignKey, $models->modelKeys()) + ->whereIn($relationship->getProperty()->getAttribute()->foreignKey, $models->modelKeys()) ->get(); if (! $children->isEmpty()) { @@ -316,8 +314,8 @@ protected function resolveHasManyRelationship( return $this->model::class === $property->getType(); }); - $models->map(function (DatabaseModel $model) use ($children, $hasManyProperty, $chaperoneProperty): DatabaseModel { - $model->{$hasManyProperty->getName()} = $children->map(function (DatabaseModel $childModel) use ($model, $chaperoneProperty): DatabaseModel { + $models->map(function (DatabaseModel $model) use ($children, $relationship, $chaperoneProperty): DatabaseModel { + $model->{$relationship->getProperty()->getName()} = $children->map(function (DatabaseModel $childModel) use ($model, $chaperoneProperty): DatabaseModel { $childModel->{$chaperoneProperty->getName()} = clone $model; return $childModel; diff --git a/src/Database/Models/Relationships/BelongsTo.php b/src/Database/Models/Relationships/BelongsTo.php new file mode 100644 index 00000000..514a02c5 --- /dev/null +++ b/src/Database/Models/Relationships/BelongsTo.php @@ -0,0 +1,33 @@ +property->query(); + } + + public function getProperty(): BelongsToProperty + { + return $this->property; + } + + public function getForeignKey(): ModelProperty + { + return $this->foreignKey; + } +} diff --git a/src/Database/Models/Relationships/BelongsToRelationship.php b/src/Database/Models/Relationships/BelongsToRelationship.php deleted file mode 100644 index d48bc391..00000000 --- a/src/Database/Models/Relationships/BelongsToRelationship.php +++ /dev/null @@ -1,26 +0,0 @@ -foreignKey = $properties[$property->attribute->foreignKey]; - } - - public static function make(ModelProperty $property, array $properties): static - { - return new static($property, $properties); - } -} diff --git a/src/Database/Models/Relationships/HasMany.php b/src/Database/Models/Relationships/HasMany.php new file mode 100644 index 00000000..6109c82b --- /dev/null +++ b/src/Database/Models/Relationships/HasMany.php @@ -0,0 +1,26 @@ +property->query(); + } + + public function getProperty(): HasManyProperty + { + return $this->property; + } +} diff --git a/src/Database/Models/Relationships/Relationship.php b/src/Database/Models/Relationships/Relationship.php index 762cb2dc..8b0402ab 100644 --- a/src/Database/Models/Relationships/Relationship.php +++ b/src/Database/Models/Relationships/Relationship.php @@ -4,9 +4,29 @@ namespace Phenix\Database\Models\Relationships; -use Phenix\Database\Models\Properties\ModelProperty; +use Phenix\Database\Models\QueryBuilders\DatabaseQueryBuilder; abstract class Relationship { - abstract public static function make(ModelProperty $property, array $properties): static; + protected DatabaseQueryBuilder|null $queryBuilder; + protected bool $chaperone = false; + + abstract protected function initQueryBuilder(): DatabaseQueryBuilder; + + public function query(): DatabaseQueryBuilder + { + return $this->queryBuilder ??= $this->initQueryBuilder(); + } + + public function withChaperone(): self + { + $this->chaperone = true; + + return $this; + } + + public function assignChaperone(): bool + { + return $this->chaperone; + } } diff --git a/src/Database/Models/Relationships/RelationshipFactory.php b/src/Database/Models/Relationships/RelationshipFactory.php deleted file mode 100644 index 268773fc..00000000 --- a/src/Database/Models/Relationships/RelationshipFactory.php +++ /dev/null @@ -1,20 +0,0 @@ - BelongsToRelationship::make($property, $properties), - default => throw new InvalidArgumentException('Unknown relationship type ' . $property::class) - }; - } -} diff --git a/src/Exceptions/Database/ModelPropertyException.php b/src/Exceptions/Database/ModelException.php similarity index 68% rename from src/Exceptions/Database/ModelPropertyException.php rename to src/Exceptions/Database/ModelException.php index e68161dc..0aaa4f3a 100644 --- a/src/Exceptions/Database/ModelPropertyException.php +++ b/src/Exceptions/Database/ModelException.php @@ -6,7 +6,7 @@ use Exception; -class ModelPropertyException extends Exception +class ModelException extends Exception { // .. } diff --git a/tests/Feature/Database/DatabaseModelTest.php b/tests/Feature/Database/DatabaseModelTest.php index c867414c..4ba5db13 100644 --- a/tests/Feature/Database/DatabaseModelTest.php +++ b/tests/Feature/Database/DatabaseModelTest.php @@ -162,7 +162,7 @@ $user = User::query() ->selectAllColumns() ->whereEqual('id', $request->route('user')) - ->with('products') + ->with(['products']) ->first(); expect($user)->toBeInstanceOf(User::class); @@ -183,7 +183,7 @@ expect($product->createdAt)->toBeInstanceOf(Date::class); expect($product->userId)->toBe($userData['id']); - expect($product->user)->toBeInstanceOf(User::class); + // expect(isset($product->user))->toBeFalse(); return response()->json($user); }); @@ -193,3 +193,81 @@ $this->get('/users/' . $userData['id']) ->assertOk(); }); + +// it('loads the relationship when the model has many child models with chaperone', function () { +// $userData = [ +// 'id' => 1, +// 'name' => 'John Doe', +// 'email' => 'john.doe@email.com', +// 'created_at' => Date::now()->toDateTimeString(), +// ]; + +// $userCollection[] = $userData; + +// $productData = [ +// 'id' => 1, +// 'description' => 'Phenix shirt', +// 'price' => 100, +// 'stock' => 6, +// 'user_id' => $userData['id'], +// 'created_at' => Date::now()->toDateTimeString(), +// ]; + +// $productCollection[] = $productData; + +// $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + +// $connection->expects($this->exactly(2)) +// ->method('prepare') +// ->willReturnOnConsecutiveCalls( +// new Statement(new Result($userCollection)), +// new Statement(new Result($productCollection)), +// ); + +// $this->app->swap(Connections::default(), $connection); + +// Route::get('/users/{user}', function (Request $request) use ($userData, $productData): Response { +// /** @var User $user */ +// $user = User::query() +// ->selectAllColumns() +// ->whereEqual('id', $request->route('user')) +// ->with([ +// 'products' => HasManyBuilder::new() +// ->withChaperone() +// ->query(function ($query) { + +// }), +// 'option' => [ +// 'query' +// ] +// ]) +// ->first(); + +// expect($user)->toBeInstanceOf(User::class); + +// expect($user->id)->toBe($userData['id']); +// expect($user->name)->toBe($userData['name']); +// expect($user->email)->toBe($userData['email']); + +// expect($user->products)->toBeInstanceOf(Collection::class); +// expect($user->products->count())->toBe(1); + +// /** @var Product $products */ +// $product = $user->products->first(); + +// expect($product->id)->toBe($productData['id']); +// expect($product->description)->toBe($productData['description']); +// expect($product->price)->toBe((float) $productData['price']); +// expect($product->createdAt)->toBeInstanceOf(Date::class); +// expect($product->userId)->toBe($userData['id']); + +// expect($product->user)->toBeInstanceOf(User::class); + +// return response()->json($user); +// }); + +// $this->app->run(); + +// $this->get('/users/' . $userData['id']) +// ->assertOk(); +// }); From 3a6e01ef05052c30b6f0af3702e945fd904ce814 Mon Sep 17 00:00:00 2001 From: Omar Barbosa Date: Mon, 28 Oct 2024 18:10:12 -0500 Subject: [PATCH 11/44] feat: load children without chaperone --- src/Database/Models/Attributes/HasMany.php | 3 +- .../QueryBuilders/DatabaseQueryBuilder.php | 14 +- tests/Feature/Database/DatabaseModelTest.php | 202 ++++-------------- 3 files changed, 57 insertions(+), 162 deletions(-) diff --git a/src/Database/Models/Attributes/HasMany.php b/src/Database/Models/Attributes/HasMany.php index 860da3a9..6a71d298 100644 --- a/src/Database/Models/Attributes/HasMany.php +++ b/src/Database/Models/Attributes/HasMany.php @@ -11,7 +11,8 @@ { public function __construct( public string $model, - public string $foreignKey + public string $foreignKey, + public bool $chaperone = false, ) { } diff --git a/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php b/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php index baa1a3a3..89946d76 100644 --- a/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php +++ b/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php @@ -315,11 +315,17 @@ protected function resolveHasManyRelationship( }); $models->map(function (DatabaseModel $model) use ($children, $relationship, $chaperoneProperty): DatabaseModel { - $model->{$relationship->getProperty()->getName()} = $children->map(function (DatabaseModel $childModel) use ($model, $chaperoneProperty): DatabaseModel { - $childModel->{$chaperoneProperty->getName()} = clone $model; + $records = $children->filter(fn (DatabaseModel $record) => $model->getKey() === $record->getKey()); - return $childModel; - }); + if ($relationship->getProperty()->getAttribute()->chaperone) { + $model->{$relationship->getProperty()->getName()} = $records->map(function (DatabaseModel $childModel) use ($model, $chaperoneProperty): DatabaseModel { + $childModel->{$chaperoneProperty->getName()} = clone $model; + + return $childModel; + }); + } else { + $model->{$relationship->getProperty()->getName()} = $records; + } return $model; }); diff --git a/tests/Feature/Database/DatabaseModelTest.php b/tests/Feature/Database/DatabaseModelTest.php index 4ba5db13..c26e53af 100644 --- a/tests/Feature/Database/DatabaseModelTest.php +++ b/tests/Feature/Database/DatabaseModelTest.php @@ -4,9 +4,6 @@ use Phenix\Database\Constants\Connections; use Phenix\Database\Models\Collection; -use Phenix\Facades\Route; -use Phenix\Http\Request; -use Phenix\Http\Response; use Phenix\Util\Date; use Tests\Feature\Database\Models\Post; use Tests\Feature\Database\Models\Product; @@ -17,10 +14,6 @@ use function Pest\Faker\faker; -afterEach(function () { - $this->app->stop(); -}); - it('creates models with query builders successfully', function () { $data = [ [ @@ -41,28 +34,19 @@ $this->app->swap(Connections::default(), $connection); - Route::get('/users', function (Request $request) use ($data): Response { - $users = User::query()->selectAllColumns()->get(); - - expect($users)->toBeInstanceOf(Collection::class); - expect($users->first())->toBeInstanceOf(User::class); - - /** @var User $user */ - $user = $users->first(); + $users = User::query()->selectAllColumns()->get(); - expect($user->id)->toBe($data[0]['id']); - expect($user->name)->toBe($data[0]['name']); - expect($user->email)->toBe($data[0]['email']); - expect($user->createdAt)->toBeInstanceOf(Date::class); - expect($user->updatedAt)->toBeNull(); + expect($users)->toBeInstanceOf(Collection::class); + expect($users->first())->toBeInstanceOf(User::class); - return response()->json($users); - }); + /** @var User $user */ + $user = $users->first(); - $this->app->run(); - - $this->get('/users', $data) - ->assertOk(); + expect($user->id)->toBe($data[0]['id']); + expect($user->name)->toBe($data[0]['name']); + expect($user->email)->toBe($data[0]['email']); + expect($user->createdAt)->toBeInstanceOf(Date::class); + expect($user->updatedAt)->toBeNull(); }); it('loads the relationship when the model belongs to a parent model', function () { @@ -96,33 +80,24 @@ $this->app->swap(Connections::default(), $connection); - Route::get('/posts/{post}', function (Request $request) use ($postData, $userData): Response { - /** @var Post $post */ - $post = Post::query()->selectAllColumns() - ->with('user') - ->first(); - - expect($post)->toBeInstanceOf(Post::class); - - expect($post->id)->toBe($postData['id']); - expect($post->title)->toBe($postData['title']); - expect($post->content)->toBe($postData['content']); - expect($post->createdAt)->toBeInstanceOf(Date::class); - expect($post->updatedAt)->toBeNull(); + /** @var Post $post */ + $post = Post::query()->selectAllColumns() + ->with('user') + ->first(); - expect($post->user)->toBeInstanceOf(User::class); + expect($post)->toBeInstanceOf(Post::class); - expect($post->user->id)->toBe($userData['id']); - expect($post->user->name)->toBe($userData['name']); - expect($post->user->email)->toBe($userData['email']); + expect($post->id)->toBe($postData['id']); + expect($post->title)->toBe($postData['title']); + expect($post->content)->toBe($postData['content']); + expect($post->createdAt)->toBeInstanceOf(Date::class); + expect($post->updatedAt)->toBeNull(); - return response()->json($post); - }); + expect($post->user)->toBeInstanceOf(User::class); - $this->app->run(); - - $this->get('/posts/' . $postData['id']) - ->assertOk(); + expect($post->user->id)->toBe($userData['id']); + expect($post->user->name)->toBe($userData['name']); + expect($post->user->email)->toBe($userData['email']); }); it('loads the relationship when the model has many child models', function () { @@ -157,117 +132,30 @@ $this->app->swap(Connections::default(), $connection); - Route::get('/users/{user}', function (Request $request) use ($userData, $productData): Response { - /** @var User $user */ - $user = User::query() - ->selectAllColumns() - ->whereEqual('id', $request->route('user')) - ->with(['products']) - ->first(); - - expect($user)->toBeInstanceOf(User::class); - - expect($user->id)->toBe($userData['id']); - expect($user->name)->toBe($userData['name']); - expect($user->email)->toBe($userData['email']); + /** @var User $user */ + $user = User::query() + ->selectAllColumns() + ->whereEqual('id', 1) + ->with(['products']) + ->first(); - expect($user->products)->toBeInstanceOf(Collection::class); - expect($user->products->count())->toBe(1); + expect($user)->toBeInstanceOf(User::class); - /** @var Product $products */ - $product = $user->products->first(); + expect($user->id)->toBe($userData['id']); + expect($user->name)->toBe($userData['name']); + expect($user->email)->toBe($userData['email']); - expect($product->id)->toBe($productData['id']); - expect($product->description)->toBe($productData['description']); - expect($product->price)->toBe((float) $productData['price']); - expect($product->createdAt)->toBeInstanceOf(Date::class); - expect($product->userId)->toBe($userData['id']); + expect($user->products)->toBeInstanceOf(Collection::class); + expect($user->products->count())->toBe(1); - // expect(isset($product->user))->toBeFalse(); + /** @var Product $products */ + $product = $user->products->first(); - return response()->json($user); - }); - - $this->app->run(); - - $this->get('/users/' . $userData['id']) - ->assertOk(); -}); + expect($product->id)->toBe($productData['id']); + expect($product->description)->toBe($productData['description']); + expect($product->price)->toBe((float) $productData['price']); + expect($product->createdAt)->toBeInstanceOf(Date::class); + expect($product->userId)->toBe($userData['id']); -// it('loads the relationship when the model has many child models with chaperone', function () { -// $userData = [ -// 'id' => 1, -// 'name' => 'John Doe', -// 'email' => 'john.doe@email.com', -// 'created_at' => Date::now()->toDateTimeString(), -// ]; - -// $userCollection[] = $userData; - -// $productData = [ -// 'id' => 1, -// 'description' => 'Phenix shirt', -// 'price' => 100, -// 'stock' => 6, -// 'user_id' => $userData['id'], -// 'created_at' => Date::now()->toDateTimeString(), -// ]; - -// $productCollection[] = $productData; - -// $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); - -// $connection->expects($this->exactly(2)) -// ->method('prepare') -// ->willReturnOnConsecutiveCalls( -// new Statement(new Result($userCollection)), -// new Statement(new Result($productCollection)), -// ); - -// $this->app->swap(Connections::default(), $connection); - -// Route::get('/users/{user}', function (Request $request) use ($userData, $productData): Response { -// /** @var User $user */ -// $user = User::query() -// ->selectAllColumns() -// ->whereEqual('id', $request->route('user')) -// ->with([ -// 'products' => HasManyBuilder::new() -// ->withChaperone() -// ->query(function ($query) { - -// }), -// 'option' => [ -// 'query' -// ] -// ]) -// ->first(); - -// expect($user)->toBeInstanceOf(User::class); - -// expect($user->id)->toBe($userData['id']); -// expect($user->name)->toBe($userData['name']); -// expect($user->email)->toBe($userData['email']); - -// expect($user->products)->toBeInstanceOf(Collection::class); -// expect($user->products->count())->toBe(1); - -// /** @var Product $products */ -// $product = $user->products->first(); - -// expect($product->id)->toBe($productData['id']); -// expect($product->description)->toBe($productData['description']); -// expect($product->price)->toBe((float) $productData['price']); -// expect($product->createdAt)->toBeInstanceOf(Date::class); -// expect($product->userId)->toBe($userData['id']); - -// expect($product->user)->toBeInstanceOf(User::class); - -// return response()->json($user); -// }); - -// $this->app->run(); - -// $this->get('/users/' . $userData['id']) -// ->assertOk(); -// }); + expect(isset($product->user))->toBeFalse(); +}); \ No newline at end of file From d2f59701901f725315d0071403bb6d8e7f893746 Mon Sep 17 00:00:00 2001 From: Omar Barbosa Date: Mon, 28 Oct 2024 18:17:17 -0500 Subject: [PATCH 12/44] feat: load relationship with chaperone --- tests/Feature/Database/DatabaseModelTest.php | 75 +++++++++++++++++--- tests/Feature/Database/Models/Comment.php | 38 ++++++++++ tests/Feature/Database/Models/User.php | 3 + 3 files changed, 108 insertions(+), 8 deletions(-) create mode 100644 tests/Feature/Database/Models/Comment.php diff --git a/tests/Feature/Database/DatabaseModelTest.php b/tests/Feature/Database/DatabaseModelTest.php index c26e53af..1c950534 100644 --- a/tests/Feature/Database/DatabaseModelTest.php +++ b/tests/Feature/Database/DatabaseModelTest.php @@ -2,17 +2,18 @@ declare(strict_types=1); -use Phenix\Database\Constants\Connections; -use Phenix\Database\Models\Collection; use Phenix\Util\Date; -use Tests\Feature\Database\Models\Post; -use Tests\Feature\Database\Models\Product; -use Tests\Feature\Database\Models\User; -use Tests\Mocks\Database\MysqlConnectionPool; +use function Pest\Faker\faker; use Tests\Mocks\Database\Result; use Tests\Mocks\Database\Statement; +use Phenix\Database\Models\Collection; +use Tests\Feature\Database\Models\Post; +use Tests\Feature\Database\Models\User; +use Phenix\Database\Constants\Connections; +use Tests\Feature\Database\Models\Comment; -use function Pest\Faker\faker; +use Tests\Feature\Database\Models\Product; +use Tests\Mocks\Database\MysqlConnectionPool; it('creates models with query builders successfully', function () { $data = [ @@ -100,7 +101,7 @@ expect($post->user->email)->toBe($userData['email']); }); -it('loads the relationship when the model has many child models', function () { +it('loads the relationship when the model has many child models without chaperone', function () { $userData = [ 'id' => 1, 'name' => 'John Doe', @@ -158,4 +159,62 @@ expect($product->userId)->toBe($userData['id']); expect(isset($product->user))->toBeFalse(); +}); + +it('loads the relationship when the model has many child models with chaperone', function () { + $userData = [ + 'id' => 1, + 'name' => 'John Doe', + 'email' => 'john.doe@email.com', + 'created_at' => Date::now()->toDateTimeString(), + ]; + + $userCollection[] = $userData; + + $commentData = [ + 'id' => 1, + 'content' => 'PHP is awesome', + 'user_id' => $userData['id'], + 'created_at' => Date::now()->toDateTimeString(), + ]; + + $commentCollection[] = $commentData; + + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->exactly(2)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result($userCollection)), + new Statement(new Result($commentCollection)), + ); + + $this->app->swap(Connections::default(), $connection); + + /** @var User $user */ + $user = User::query() + ->selectAllColumns() + ->whereEqual('id', 1) + ->with(['comments']) + ->first(); + + expect($user)->toBeInstanceOf(User::class); + + expect($user->id)->toBe($userData['id']); + expect($user->name)->toBe($userData['name']); + expect($user->email)->toBe($userData['email']); + + expect($user->comments)->toBeInstanceOf(Collection::class); + expect($user->comments->count())->toBe(1); + + /** @var Comment $comments */ + $comment = $user->comments->first(); + + expect($comment->id)->toBe($commentData['id']); + expect($comment->content)->toBe($commentData['content']); + expect($comment->createdAt)->toBeInstanceOf(Date::class); + expect($comment->userId)->toBe($userData['id']); + + expect(isset($comment->user))->toBeTrue(); + expect($comment->user->id)->toBe($userData['id']); }); \ No newline at end of file diff --git a/tests/Feature/Database/Models/Comment.php b/tests/Feature/Database/Models/Comment.php new file mode 100644 index 00000000..8b7ff1c1 --- /dev/null +++ b/tests/Feature/Database/Models/Comment.php @@ -0,0 +1,38 @@ + Date: Wed, 30 Oct 2024 18:39:30 -0500 Subject: [PATCH 13/44] feat: load chaperone from relationship method --- .../QueryBuilders/DatabaseQueryBuilder.php | 36 +++++++--- src/Database/Models/Relationships/HasMany.php | 1 + .../Models/Relationships/Relationship.php | 2 +- tests/Feature/Database/DatabaseModelTest.php | 70 ++++++++++++++++++- 4 files changed, 98 insertions(+), 11 deletions(-) diff --git a/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php b/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php index 89946d76..7f1cba8a 100644 --- a/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php +++ b/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php @@ -7,6 +7,7 @@ use Amp\Sql\Common\SqlCommonConnectionPool; use Amp\Sql\SqlQueryError; use Amp\Sql\SqlTransactionError; +use Closure; use League\Uri\Components\Query; use League\Uri\Http; use Phenix\App; @@ -189,14 +190,22 @@ public function with(array|string $relationships): self $modelRelationships = $this->model->getRelationshipBindings(); - foreach ($relationships as $relationshipName) { + foreach ($relationships as $key => $value) { + if ($value instanceof Closure) { + $relationshipName = $key; + $closure = $value; + } else { + $relationshipName = $value; + $closure = null; + } + $relationship = $modelRelationships[$relationshipName] ?? null; if (! $relationship) { throw new ModelException("Undefined relationship {$relationshipName} for " . $this->model::class); } - $this->relationships[] = $relationship; + $this->relationships[] = [$relationship, $closure]; } return $this; @@ -208,6 +217,7 @@ public function with(array|string $relationships): self public function get(): Collection { $this->action = Actions::SELECT; + $this->columns = empty($this->columns) ? ['*'] : $this->columns; [$dml, $params] = $this->toSql(); @@ -264,18 +274,24 @@ protected function mapToModel(array $row): DatabaseModel protected function resolveRelationships(Collection $collection): void { - foreach ($this->relationships as $relationship) { + foreach ($this->relationships as [$relationship, $closure]) { if ($relationship instanceof BelongsTo) { - $this->resolveBelongsToRelationship(...[$collection, $relationship]); + $this->resolveBelongsToRelationship($collection, $relationship, $closure); } elseif ($relationship instanceof HasMany) { - $this->resolveHasManyRelationship($collection, $relationship); + $this->resolveHasManyRelationship($collection, $relationship, $closure); } } } + /** + * @param Collection $models + * @param BelongsTo $relationship + * @param Closure|null $closure + */ protected function resolveBelongsToRelationship( Collection $models, - BelongsTo $relationship + BelongsTo $relationship, + Closure|null $closure = null ): void { /** @var Collection $records */ $records = $relationship->query() @@ -301,10 +317,14 @@ protected function resolveBelongsToRelationship( protected function resolveHasManyRelationship( Collection $models, HasMany $relationship, + Closure|null $closure = null ): void { + if ($closure) { + $closure($relationship); + } + /** @var Collection $children */ $children = $relationship->query() - ->selectAllColumns() ->whereIn($relationship->getProperty()->getAttribute()->foreignKey, $models->modelKeys()) ->get(); @@ -317,7 +337,7 @@ protected function resolveHasManyRelationship( $models->map(function (DatabaseModel $model) use ($children, $relationship, $chaperoneProperty): DatabaseModel { $records = $children->filter(fn (DatabaseModel $record) => $model->getKey() === $record->getKey()); - if ($relationship->getProperty()->getAttribute()->chaperone) { + if ($relationship->getProperty()->getAttribute()->chaperone || $relationship->assignChaperone()) { $model->{$relationship->getProperty()->getName()} = $records->map(function (DatabaseModel $childModel) use ($model, $chaperoneProperty): DatabaseModel { $childModel->{$chaperoneProperty->getName()} = clone $model; diff --git a/src/Database/Models/Relationships/HasMany.php b/src/Database/Models/Relationships/HasMany.php index 6109c82b..71f6e820 100644 --- a/src/Database/Models/Relationships/HasMany.php +++ b/src/Database/Models/Relationships/HasMany.php @@ -12,6 +12,7 @@ class HasMany extends Relationship public function __construct( protected HasManyProperty $property, ) { + $this->queryBuilder = null; } protected function initQueryBuilder(): DatabaseQueryBuilder diff --git a/src/Database/Models/Relationships/Relationship.php b/src/Database/Models/Relationships/Relationship.php index 8b0402ab..202f7bd4 100644 --- a/src/Database/Models/Relationships/Relationship.php +++ b/src/Database/Models/Relationships/Relationship.php @@ -8,7 +8,7 @@ abstract class Relationship { - protected DatabaseQueryBuilder|null $queryBuilder; + protected DatabaseQueryBuilder|null $queryBuilder = null; protected bool $chaperone = false; abstract protected function initQueryBuilder(): DatabaseQueryBuilder; diff --git a/tests/Feature/Database/DatabaseModelTest.php b/tests/Feature/Database/DatabaseModelTest.php index 1c950534..a82b3161 100644 --- a/tests/Feature/Database/DatabaseModelTest.php +++ b/tests/Feature/Database/DatabaseModelTest.php @@ -10,6 +10,7 @@ use Tests\Feature\Database\Models\Post; use Tests\Feature\Database\Models\User; use Phenix\Database\Constants\Connections; +use Phenix\Database\Models\Relationships\HasMany; use Tests\Feature\Database\Models\Comment; use Tests\Feature\Database\Models\Product; @@ -161,7 +162,72 @@ expect(isset($product->user))->toBeFalse(); }); -it('loads the relationship when the model has many child models with chaperone', function () { +it('loads the relationship when the model has many child models loading chaperone from relationship method', function () { + $userData = [ + 'id' => 1, + 'name' => 'John Doe', + 'email' => 'john.doe@email.com', + 'created_at' => Date::now()->toDateTimeString(), + ]; + + $userCollection[] = $userData; + + $productData = [ + 'id' => 1, + 'description' => 'Phenix shirt', + 'price' => 100, + 'stock' => 6, + 'user_id' => $userData['id'], + 'created_at' => Date::now()->toDateTimeString(), + ]; + + $productCollection[] = $productData; + + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->exactly(2)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result($userCollection)), + new Statement(new Result($productCollection)), + ); + + $this->app->swap(Connections::default(), $connection); + + /** @var User $user */ + $user = User::query() + ->selectAllColumns() + ->whereEqual('id', 1) + ->with([ + 'products' => function (HasMany $hasMany): void { + $hasMany->withChaperone(); + }, + ]) + ->first(); + + expect($user)->toBeInstanceOf(User::class); + + expect($user->id)->toBe($userData['id']); + expect($user->name)->toBe($userData['name']); + expect($user->email)->toBe($userData['email']); + + expect($user->products)->toBeInstanceOf(Collection::class); + expect($user->products->count())->toBe(1); + + /** @var Product $products */ + $product = $user->products->first(); + + expect($product->id)->toBe($productData['id']); + expect($product->description)->toBe($productData['description']); + expect($product->price)->toBe((float) $productData['price']); + expect($product->createdAt)->toBeInstanceOf(Date::class); + expect($product->userId)->toBe($userData['id']); + + expect(isset($product->user))->toBeTrue(); + expect($product->user->id)->toBe($userData['id']); +}); + +it('loads the relationship when the model has many child models loading chaperone by default', function () { $userData = [ 'id' => 1, 'name' => 'John Doe', @@ -217,4 +283,4 @@ expect(isset($comment->user))->toBeTrue(); expect($comment->user->id)->toBe($userData['id']); -}); \ No newline at end of file +}); From 73c34d7bc74e41136477c6779ad54f5d84c56f44 Mon Sep 17 00:00:00 2001 From: Omar Barbosa Date: Tue, 5 Nov 2024 17:49:43 -0500 Subject: [PATCH 14/44] feat: load relationship with column selection --- .../QueryBuilders/DatabaseQueryBuilder.php | 9 ++- tests/Feature/Database/DatabaseModelTest.php | 55 +++++++++++++++++++ 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php b/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php index 7f1cba8a..15d2cc88 100644 --- a/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php +++ b/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php @@ -196,7 +196,7 @@ public function with(array|string $relationships): self $closure = $value; } else { $relationshipName = $value; - $closure = null; + $closure = fn ($builder) => $builder->query(); } $relationship = $modelRelationships[$relationshipName] ?? null; @@ -293,9 +293,10 @@ protected function resolveBelongsToRelationship( BelongsTo $relationship, Closure|null $closure = null ): void { + $closure($relationship); + /** @var Collection $records */ $records = $relationship->query() - ->selectAllColumns() ->whereIn($relationship->getForeignKey()->getAttribute()->getColumnName(), $models->modelKeys()) ->get(); @@ -319,9 +320,7 @@ protected function resolveHasManyRelationship( HasMany $relationship, Closure|null $closure = null ): void { - if ($closure) { - $closure($relationship); - } + $closure($relationship); /** @var Collection $children */ $children = $relationship->query() diff --git a/tests/Feature/Database/DatabaseModelTest.php b/tests/Feature/Database/DatabaseModelTest.php index a82b3161..9360eec5 100644 --- a/tests/Feature/Database/DatabaseModelTest.php +++ b/tests/Feature/Database/DatabaseModelTest.php @@ -10,6 +10,7 @@ use Tests\Feature\Database\Models\Post; use Tests\Feature\Database\Models\User; use Phenix\Database\Constants\Connections; +use Phenix\Database\Models\Relationships\BelongsTo; use Phenix\Database\Models\Relationships\HasMany; use Tests\Feature\Database\Models\Comment; @@ -102,6 +103,60 @@ expect($post->user->email)->toBe($userData['email']); }); +it('loads the relationship when the model belongs to a parent model with column selection', function () { + $userData = [ + 'id' => 1, + 'name' => 'John Doe', + ]; + + $userCollection[] = $userData; + + $postData = [ + 'id' => 1, + 'title' => 'PHP is great', + 'content' => faker()->sentence(), + 'user_id' => $userData['id'], + 'created_at' => Date::now()->toDateTimeString(), + ]; + + $postCollection[] = $postData; + + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->exactly(2)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result($postCollection)), + new Statement(new Result($userCollection)), + ); + + $this->app->swap(Connections::default(), $connection); + + /** @var Post $post */ + $post = Post::query()->selectAllColumns() + ->with([ + 'user' => function (BelongsTo $belongsTo) { + $belongsTo->query() + ->select(['id', 'name']); + } + ]) + ->first(); + + expect($post)->toBeInstanceOf(Post::class); + + expect($post->id)->toBe($postData['id']); + expect($post->title)->toBe($postData['title']); + expect($post->content)->toBe($postData['content']); + expect($post->createdAt)->toBeInstanceOf(Date::class); + expect($post->updatedAt)->toBeNull(); + + expect($post->user)->toBeInstanceOf(User::class); + + expect($post->user->id)->toBe($userData['id']); + expect($post->user->name)->toBe($userData['name']); + expect(isset($post->user->email))->toBeFalse(); +}); + it('loads the relationship when the model has many child models without chaperone', function () { $userData = [ 'id' => 1, From 6bc7179ae013bc8cbc6ebd441d403d85bb6e43c3 Mon Sep 17 00:00:00 2001 From: Omar Barbosa Date: Sun, 17 Nov 2024 15:18:42 -0500 Subject: [PATCH 15/44] feat: create alias from key and value on select columns --- src/Database/Concerns/Query/PrepareColumns.php | 16 ++++++++++------ .../QueryGenerator/SelectColumnsTest.php | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/src/Database/Concerns/Query/PrepareColumns.php b/src/Database/Concerns/Query/PrepareColumns.php index 7c986fb8..37cf4934 100644 --- a/src/Database/Concerns/Query/PrepareColumns.php +++ b/src/Database/Concerns/Query/PrepareColumns.php @@ -4,24 +4,28 @@ namespace Phenix\Database\Concerns\Query; +use Phenix\Database\Alias; use Phenix\Database\Functions; use Phenix\Database\SelectCase; use Phenix\Database\Subquery; use Phenix\Exceptions\QueryError; use Phenix\Util\Arr; +use function is_string; + trait PrepareColumns { protected function prepareColumns(array $columns): string { - $columns = array_map(function ($column) { + $columns = Arr::map($columns, function (string|Functions|SelectCase|Subquery $value, int|string $key): string { return match (true) { - $column instanceof Functions => (string) $column, - $column instanceof SelectCase => (string) $column, - $column instanceof Subquery => $this->resolveSubquery($column), - default => $column, + is_string($key) => (string) Alias::of($key)->as($value), + $value instanceof Functions => (string) $value, + $value instanceof SelectCase => (string) $value, + $value instanceof Subquery => $this->resolveSubquery($value), + default => $value, }; - }, $columns); + }); return Arr::implodeDeeply($columns, ', '); } diff --git a/tests/Unit/Database/QueryGenerator/SelectColumnsTest.php b/tests/Unit/Database/QueryGenerator/SelectColumnsTest.php index f2b60eaf..4655de36 100644 --- a/tests/Unit/Database/QueryGenerator/SelectColumnsTest.php +++ b/tests/Unit/Database/QueryGenerator/SelectColumnsTest.php @@ -160,6 +160,24 @@ expect($params)->toBeEmpty(); }); +it('generates query with many column alias', function () { + $query = new QueryGenerator(); + + $sql = $query->select([ + 'id' => 'model_id', + 'name' => 'full_name', + ]) + ->from('users') + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT id AS model_id, name AS full_name FROM users"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + it('generates query with select-cases using comparisons', function ( string $method, array $data, From ff590435bd0c1123154723b3ee79f7864ca58118 Mon Sep 17 00:00:00 2001 From: Omar Barbosa Date: Sun, 17 Nov 2024 15:20:19 -0500 Subject: [PATCH 16/44] feat: belongs to many relationship --- .../Models/Attributes/BelongsToMany.php | 24 +++++ src/Database/Models/DatabaseModel.php | 30 +++++-- .../Properties/BelongsToManyProperty.php | 21 +++++ .../QueryBuilders/DatabaseQueryBuilder.php | 52 ++++++++++- .../Models/Relationships/BelongsTo.php | 2 +- .../Models/Relationships/BelongsToMany.php | 27 ++++++ .../Relationships/BelongsToRelationship.php | 22 +++++ src/Database/Models/Relationships/HasMany.php | 2 +- .../Models/Relationships/Relationship.php | 13 --- tests/Feature/Database/DatabaseModelTest.php | 88 ++++++++++++++++--- tests/Feature/Database/Models/Invoice.php | 51 +++++++++++ tests/Feature/Database/Models/Product.php | 10 +++ 12 files changed, 304 insertions(+), 38 deletions(-) create mode 100644 src/Database/Models/Attributes/BelongsToMany.php create mode 100644 src/Database/Models/Properties/BelongsToManyProperty.php create mode 100644 src/Database/Models/Relationships/BelongsToMany.php create mode 100644 src/Database/Models/Relationships/BelongsToRelationship.php create mode 100644 tests/Feature/Database/Models/Invoice.php diff --git a/src/Database/Models/Attributes/BelongsToMany.php b/src/Database/Models/Attributes/BelongsToMany.php new file mode 100644 index 00000000..661af31a --- /dev/null +++ b/src/Database/Models/Attributes/BelongsToMany.php @@ -0,0 +1,24 @@ +|null */ @@ -47,6 +53,7 @@ public function __construct() $this->queryBuilder = null; $this->propertyBindings = null; $this->relationshipBindings = null; + $this->pivot = new stdClass(); } abstract protected static function table(): string; @@ -87,13 +94,14 @@ public function getTable(): string public function getKey(): string|int { - if (! $this->modelKey) { - $this->modelKey = Arr::first($this->getPropertyBindings(), function (ModelProperty $property): bool { - return $property->getAttribute() instanceof Id; - }); - } + return $this->{$this->getModelKeyName()}; + } + + public function getModelKeyName(): string + { + $this->modelKey ??= $this->findModelKey(); - return $this->{$this->modelKey->getName()}; + return $this->modelKey->getName(); } public function toArray(): array @@ -165,6 +173,8 @@ protected function buildRelationshipBindings(): array $relationships[$property->getName()] = $this->buildBelongsToRelationship($property); } elseif ($property instanceof HasManyProperty) { $relationships[$property->getName()] = new HasMany($property); + } elseif ($property instanceof BelongsToManyProperty) { + $relationships[$property->getName()] = new BelongsToMany($property); } } @@ -184,6 +194,7 @@ class_exists((string) $property->getType()), return match($attribute::class) { BelongsToAttribute::class => new BelongsToProperty(...$arguments), HasManyAttribute::class => new HasManyProperty(...$arguments), + BelongsToManyAttribute::class => new BelongsToManyProperty(...$arguments), default => new ModelProperty(...$arguments), }; } @@ -201,6 +212,13 @@ protected function buildBelongsToRelationship(BelongsToProperty $property): Belo return new BelongsTo($property, $foreignKey); } + protected function findModelKey(): ModelProperty + { + return Arr::first($this->getPropertyBindings(), function (ModelProperty $property): bool { + return $property->getAttribute() instanceof Id; + }); + } + // Relationships // API: save, delete, update, updateOr, first, alone, firstOr, get, cursor, paginate diff --git a/src/Database/Models/Properties/BelongsToManyProperty.php b/src/Database/Models/Properties/BelongsToManyProperty.php new file mode 100644 index 00000000..362a3c7f --- /dev/null +++ b/src/Database/Models/Properties/BelongsToManyProperty.php @@ -0,0 +1,21 @@ +attribute; + } + + public function query(): DatabaseQueryBuilder + { + return $this->getAttribute()->relatedModel::query(); + } +} diff --git a/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php b/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php index 15d2cc88..22bcda81 100644 --- a/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php +++ b/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php @@ -15,10 +15,12 @@ use Phenix\Database\Concerns\Query\HasJoinClause; use Phenix\Database\Constants\Actions; use Phenix\Database\Constants\Connections; +use Phenix\Database\Join; use Phenix\Database\Models\Collection; use Phenix\Database\Models\DatabaseModel; use Phenix\Database\Models\Properties\ModelProperty; use Phenix\Database\Models\Relationships\BelongsTo; +use Phenix\Database\Models\Relationships\BelongsToMany; use Phenix\Database\Models\Relationships\HasMany; use Phenix\Database\Models\Relationships\Relationship; use Phenix\Database\Paginator; @@ -249,7 +251,7 @@ public function first(): DatabaseModel } /** - * @param array $row + * @param array $row * @return DatabaseModel */ protected function mapToModel(array $row): DatabaseModel @@ -264,6 +266,10 @@ protected function mapToModel(array $row): DatabaseModel $property = $propertyBindings[$columnName]; $model->{$property->getName()} = $property->isInstantiable() ? $property->resolveInstance($value) : $value; + } elseif (str_starts_with($columnName, 'pivot_')) { + $columnName = str_replace('pivot_', '', $columnName); + + $model->pivot->{$columnName} = $value; } else { throw new ModelException("Unknown column '{$columnName}' for model " . $model::class); } @@ -279,6 +285,8 @@ protected function resolveRelationships(Collection $collection): void $this->resolveBelongsToRelationship($collection, $relationship, $closure); } elseif ($relationship instanceof HasMany) { $this->resolveHasManyRelationship($collection, $relationship, $closure); + } elseif ($relationship instanceof BelongsToMany) { + $this->resolveBelongsToManyRelationship($collection, $relationship, $closure); } } } @@ -291,7 +299,7 @@ protected function resolveRelationships(Collection $collection): void protected function resolveBelongsToRelationship( Collection $models, BelongsTo $relationship, - Closure|null $closure = null + Closure $closure ): void { $closure($relationship); @@ -318,7 +326,7 @@ protected function resolveBelongsToRelationship( protected function resolveHasManyRelationship( Collection $models, HasMany $relationship, - Closure|null $closure = null + Closure $closure ): void { $closure($relationship); @@ -350,4 +358,42 @@ protected function resolveHasManyRelationship( }); } } + + /** + * @param Collection $models + * @param BelongsToMany $relationship + */ + protected function resolveBelongsToManyRelationship( + Collection $models, + BelongsToMany $relationship, + Closure $closure + ): void { + $closure($relationship); + + $attr = $relationship->getProperty()->getAttribute(); + + /** @var Collection $related */ + $related = $relationship->query() + ->select([ + $attr->relatedModel::table() . '.*', + "{$attr->table}.{$attr->foreignKey}" => "pivot_{$attr->foreignKey}", + "{$attr->table}.{$attr->relatedForeignKey}" => "pivot_{$attr->relatedForeignKey}", + ]) + ->innerJoin($attr->table, function (Join $join) use ($attr): void { + $join->onEqual( + $this->model->getTable() . '.' . $this->model->getModelKeyName(), + $attr->table . '.' . $attr->relatedForeignKey + ); + }) + ->whereIn("{$attr->table}.{$attr->foreignKey}", $models->modelKeys()) + ->get(); + + $models->map(function (DatabaseModel $model) use ($relationship, $attr, $related): DatabaseModel { + $records = $related->filter(fn (DatabaseModel $record): bool => $model->getKey() === $record->pivot->{$attr->foreignKey}); + + $model->{$relationship->getProperty()->getName()} = $records; + + return $model; + }); + } } diff --git a/src/Database/Models/Relationships/BelongsTo.php b/src/Database/Models/Relationships/BelongsTo.php index 514a02c5..888ba547 100644 --- a/src/Database/Models/Relationships/BelongsTo.php +++ b/src/Database/Models/Relationships/BelongsTo.php @@ -8,7 +8,7 @@ use Phenix\Database\Models\Properties\ModelProperty; use Phenix\Database\Models\QueryBuilders\DatabaseQueryBuilder; -class BelongsTo extends Relationship +class BelongsTo extends BelongsToRelationship { public function __construct( protected BelongsToProperty $property, diff --git a/src/Database/Models/Relationships/BelongsToMany.php b/src/Database/Models/Relationships/BelongsToMany.php new file mode 100644 index 00000000..1ba19fa5 --- /dev/null +++ b/src/Database/Models/Relationships/BelongsToMany.php @@ -0,0 +1,27 @@ +queryBuilder = null; + } + + protected function initQueryBuilder(): DatabaseQueryBuilder + { + return $this->property->query(); + } + + public function getProperty(): BelongsToManyProperty + { + return $this->property; + } +} diff --git a/src/Database/Models/Relationships/BelongsToRelationship.php b/src/Database/Models/Relationships/BelongsToRelationship.php new file mode 100644 index 00000000..8b8c6830 --- /dev/null +++ b/src/Database/Models/Relationships/BelongsToRelationship.php @@ -0,0 +1,22 @@ +chaperone = true; + + return $this; + } + + public function assignChaperone(): bool + { + return $this->chaperone; + } +} diff --git a/src/Database/Models/Relationships/HasMany.php b/src/Database/Models/Relationships/HasMany.php index 71f6e820..8a90d449 100644 --- a/src/Database/Models/Relationships/HasMany.php +++ b/src/Database/Models/Relationships/HasMany.php @@ -7,7 +7,7 @@ use Phenix\Database\Models\Properties\HasManyProperty; use Phenix\Database\Models\QueryBuilders\DatabaseQueryBuilder; -class HasMany extends Relationship +class HasMany extends BelongsToRelationship { public function __construct( protected HasManyProperty $property, diff --git a/src/Database/Models/Relationships/Relationship.php b/src/Database/Models/Relationships/Relationship.php index 202f7bd4..87d0e126 100644 --- a/src/Database/Models/Relationships/Relationship.php +++ b/src/Database/Models/Relationships/Relationship.php @@ -9,7 +9,6 @@ abstract class Relationship { protected DatabaseQueryBuilder|null $queryBuilder = null; - protected bool $chaperone = false; abstract protected function initQueryBuilder(): DatabaseQueryBuilder; @@ -17,16 +16,4 @@ public function query(): DatabaseQueryBuilder { return $this->queryBuilder ??= $this->initQueryBuilder(); } - - public function withChaperone(): self - { - $this->chaperone = true; - - return $this; - } - - public function assignChaperone(): bool - { - return $this->chaperone; - } } diff --git a/tests/Feature/Database/DatabaseModelTest.php b/tests/Feature/Database/DatabaseModelTest.php index 9360eec5..5081c384 100644 --- a/tests/Feature/Database/DatabaseModelTest.php +++ b/tests/Feature/Database/DatabaseModelTest.php @@ -2,20 +2,21 @@ declare(strict_types=1); -use Phenix\Util\Date; -use function Pest\Faker\faker; -use Tests\Mocks\Database\Result; -use Tests\Mocks\Database\Statement; -use Phenix\Database\Models\Collection; -use Tests\Feature\Database\Models\Post; -use Tests\Feature\Database\Models\User; use Phenix\Database\Constants\Connections; +use Phenix\Database\Models\Collection; use Phenix\Database\Models\Relationships\BelongsTo; use Phenix\Database\Models\Relationships\HasMany; +use Phenix\Util\Date; use Tests\Feature\Database\Models\Comment; - +use Tests\Feature\Database\Models\Invoice; +use Tests\Feature\Database\Models\Post; use Tests\Feature\Database\Models\Product; +use Tests\Feature\Database\Models\User; use Tests\Mocks\Database\MysqlConnectionPool; +use Tests\Mocks\Database\Result; +use Tests\Mocks\Database\Statement; + +use function Pest\Faker\faker; it('creates models with query builders successfully', function () { $data = [ @@ -52,7 +53,7 @@ expect($user->updatedAt)->toBeNull(); }); -it('loads the relationship when the model belongs to a parent model', function () { +it('loads relationship when the model belongs to a parent model', function () { $userData = [ 'id' => 1, 'name' => 'John Doe', @@ -103,7 +104,7 @@ expect($post->user->email)->toBe($userData['email']); }); -it('loads the relationship when the model belongs to a parent model with column selection', function () { +it('loads relationship when the model belongs to a parent model with column selection', function () { $userData = [ 'id' => 1, 'name' => 'John Doe', @@ -138,7 +139,7 @@ 'user' => function (BelongsTo $belongsTo) { $belongsTo->query() ->select(['id', 'name']); - } + }, ]) ->first(); @@ -157,7 +158,7 @@ expect(isset($post->user->email))->toBeFalse(); }); -it('loads the relationship when the model has many child models without chaperone', function () { +it('loads relationship when the model has many child models without chaperone', function () { $userData = [ 'id' => 1, 'name' => 'John Doe', @@ -217,7 +218,7 @@ expect(isset($product->user))->toBeFalse(); }); -it('loads the relationship when the model has many child models loading chaperone from relationship method', function () { +it('loads relationship when the model has many child models loading chaperone from relationship method', function () { $userData = [ 'id' => 1, 'name' => 'John Doe', @@ -282,7 +283,7 @@ expect($product->user->id)->toBe($userData['id']); }); -it('loads the relationship when the model has many child models loading chaperone by default', function () { +it('loads relationship when the model has many child models loading chaperone by default', function () { $userData = [ 'id' => 1, 'name' => 'John Doe', @@ -339,3 +340,62 @@ expect(isset($comment->user))->toBeTrue(); expect($comment->user->id)->toBe($userData['id']); }); + +it('loads relationship when the model belongs to many models', function () { + $invoiceData = [ + 'id' => 20, + 'reference' => '1234', + 'value' => 100.0, + 'created_at' => Date::now()->toDateTimeString(), + ]; + + $invoiceCollection[] = $invoiceData; + + $productData = [ + 'id' => 122, + 'description' => 'PHP Plush', + 'price' => 50.0, + 'created_at' => Date::now()->toDateTimeString(), + 'pivot_product_id' => 122, + 'pivot_invoice_id' => 20, + ]; + + $productCollection[] = $productData; + + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->exactly(2)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result($invoiceCollection)), + new Statement(new Result($productCollection)), + ); + + $this->app->swap(Connections::default(), $connection); + + /** @var Collection $invoices */ + $invoices = Invoice::query() + ->with(['products']) + ->get(); + + expect($invoices)->toBeInstanceOf(Collection::class); + expect($invoices->count())->toBe(1); + + expect($invoices->first()->id)->toBe($invoiceData['id']); + expect($invoices->first()->reference)->toBe($invoiceData['reference']); + expect($invoices->first()->value)->toBe($invoiceData['value']); + + expect($invoices->first()->products)->toBeInstanceOf(Collection::class); + expect($invoices->first()->products->count())->toBe(1); + + /** @var Product $product */ + $product = $invoices->first()->products->first(); + + expect($product->id)->toBe($productData['id']); + expect($product->description)->toBe($productData['description']); + expect($product->price)->toBe($productData['price']); + expect($product->createdAt)->toBeInstanceOf(Date::class); + expect($product->pivot)->toBeInstanceOf(stdClass::class); + expect($product->pivot->product_id)->toBe(122); + expect($product->pivot->invoice_id)->toBe(20); +}); diff --git a/tests/Feature/Database/Models/Invoice.php b/tests/Feature/Database/Models/Invoice.php new file mode 100644 index 00000000..d79c8fcf --- /dev/null +++ b/tests/Feature/Database/Models/Invoice.php @@ -0,0 +1,51 @@ + Date: Tue, 19 Nov 2024 17:49:15 -0500 Subject: [PATCH 17/44] feat: select pivot columns --- .../QueryBuilders/DatabaseQueryBuilder.php | 14 ++-- .../Models/Relationships/BelongsTo.php | 10 +-- .../Models/Relationships/BelongsToMany.php | 37 ++++++++-- src/Database/Models/Relationships/HasMany.php | 8 +-- tests/Feature/Database/DatabaseModelTest.php | 68 +++++++++++++++++++ .../Relationships/BelongsToManyTest.php | 35 ++++++++++ 6 files changed, 149 insertions(+), 23 deletions(-) create mode 100644 tests/Unit/Database/Models/Relationships/BelongsToManyTest.php diff --git a/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php b/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php index 22bcda81..83928b30 100644 --- a/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php +++ b/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php @@ -245,9 +245,7 @@ public function first(): DatabaseModel $this->limit(1); - $record = $this->get()->first(); - - return $record; + return $this->get()->first(); } /** @@ -374,15 +372,11 @@ protected function resolveBelongsToManyRelationship( /** @var Collection $related */ $related = $relationship->query() - ->select([ - $attr->relatedModel::table() . '.*', - "{$attr->table}.{$attr->foreignKey}" => "pivot_{$attr->foreignKey}", - "{$attr->table}.{$attr->relatedForeignKey}" => "pivot_{$attr->relatedForeignKey}", - ]) + ->select($relationship->getColumns()) ->innerJoin($attr->table, function (Join $join) use ($attr): void { $join->onEqual( - $this->model->getTable() . '.' . $this->model->getModelKeyName(), - $attr->table . '.' . $attr->relatedForeignKey + "{$this->model->getTable()}.{$this->model->getModelKeyName()}", + "{$attr->table}.{$attr->relatedForeignKey}" ); }) ->whereIn("{$attr->table}.{$attr->foreignKey}", $models->modelKeys()) diff --git a/src/Database/Models/Relationships/BelongsTo.php b/src/Database/Models/Relationships/BelongsTo.php index 888ba547..5d24f3f2 100644 --- a/src/Database/Models/Relationships/BelongsTo.php +++ b/src/Database/Models/Relationships/BelongsTo.php @@ -16,11 +16,6 @@ public function __construct( ) { } - protected function initQueryBuilder(): DatabaseQueryBuilder - { - return $this->property->query(); - } - public function getProperty(): BelongsToProperty { return $this->property; @@ -30,4 +25,9 @@ public function getForeignKey(): ModelProperty { return $this->foreignKey; } + + protected function initQueryBuilder(): DatabaseQueryBuilder + { + return $this->property->query(); + } } diff --git a/src/Database/Models/Relationships/BelongsToMany.php b/src/Database/Models/Relationships/BelongsToMany.php index 1ba19fa5..65485fef 100644 --- a/src/Database/Models/Relationships/BelongsToMany.php +++ b/src/Database/Models/Relationships/BelongsToMany.php @@ -6,22 +6,51 @@ use Phenix\Database\Models\Properties\BelongsToManyProperty; use Phenix\Database\Models\QueryBuilders\DatabaseQueryBuilder; +use Phenix\Util\Arr; + +use function array_combine; class BelongsToMany extends Relationship { + protected array $pivotColumns; + public function __construct( protected BelongsToManyProperty $property, ) { $this->queryBuilder = null; + $this->pivotColumns = []; } - protected function initQueryBuilder(): DatabaseQueryBuilder + public function getProperty(): BelongsToManyProperty { - return $this->property->query(); + return $this->property; } - public function getProperty(): BelongsToManyProperty + public function withPivot(array $columns): self { - return $this->property; + $this->pivotColumns = $columns; + + return $this; + } + + public function getColumns(): array + { + $attr = $this->getProperty()->getAttribute(); + + $columns = [ + $attr->foreignKey, + $attr->relatedForeignKey, + ...$this->pivotColumns, + ]; + + $keys = Arr::map($columns, fn (string $column) => "{$attr->table}.{$column}"); + $values = Arr::map($columns, fn (string $column) => "pivot_{$column}"); + + return array_combine($keys, $values); + } + + protected function initQueryBuilder(): DatabaseQueryBuilder + { + return $this->property->query(); } } diff --git a/src/Database/Models/Relationships/HasMany.php b/src/Database/Models/Relationships/HasMany.php index 8a90d449..a526caaf 100644 --- a/src/Database/Models/Relationships/HasMany.php +++ b/src/Database/Models/Relationships/HasMany.php @@ -15,13 +15,13 @@ public function __construct( $this->queryBuilder = null; } - protected function initQueryBuilder(): DatabaseQueryBuilder + public function getProperty(): HasManyProperty { - return $this->property->query(); + return $this->property; } - public function getProperty(): HasManyProperty + protected function initQueryBuilder(): DatabaseQueryBuilder { - return $this->property; + return $this->property->query(); } } diff --git a/tests/Feature/Database/DatabaseModelTest.php b/tests/Feature/Database/DatabaseModelTest.php index 5081c384..71562927 100644 --- a/tests/Feature/Database/DatabaseModelTest.php +++ b/tests/Feature/Database/DatabaseModelTest.php @@ -5,6 +5,7 @@ use Phenix\Database\Constants\Connections; use Phenix\Database\Models\Collection; use Phenix\Database\Models\Relationships\BelongsTo; +use Phenix\Database\Models\Relationships\BelongsToMany; use Phenix\Database\Models\Relationships\HasMany; use Phenix\Util\Date; use Tests\Feature\Database\Models\Comment; @@ -399,3 +400,70 @@ expect($product->pivot->product_id)->toBe(122); expect($product->pivot->invoice_id)->toBe(20); }); + +it('loads relationship when the model belongs to many models with pivot columns', function () { + $invoiceData = [ + 'id' => 20, + 'reference' => '1234', + 'value' => 100.0, + 'created_at' => Date::now()->toDateTimeString(), + ]; + + $invoiceCollection[] = $invoiceData; + + $productData = [ + 'id' => 122, + 'description' => 'PHP Plush', + 'price' => 50.0, + 'created_at' => Date::now()->toDateTimeString(), + 'pivot_product_id' => 122, + 'pivot_invoice_id' => 20, + 'pivot_quantity' => 2, + 'pivot_value' => 100.0, + ]; + + $productCollection[] = $productData; + + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->exactly(2)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result($invoiceCollection)), + new Statement(new Result($productCollection)), + ); + + $this->app->swap(Connections::default(), $connection); + + /** @var Collection $invoices */ + $invoices = Invoice::query() + ->with([ + 'products' => function (BelongsToMany $relation) { + $relation->withPivot(['quantity', 'value']); + }, + ]) + ->get(); + + expect($invoices)->toBeInstanceOf(Collection::class); + expect($invoices->count())->toBe(1); + + expect($invoices->first()->id)->toBe($invoiceData['id']); + expect($invoices->first()->reference)->toBe($invoiceData['reference']); + expect($invoices->first()->value)->toBe($invoiceData['value']); + + expect($invoices->first()->products)->toBeInstanceOf(Collection::class); + expect($invoices->first()->products->count())->toBe(1); + + /** @var Product $product */ + $product = $invoices->first()->products->first(); + + expect($product->id)->toBe($productData['id']); + expect($product->description)->toBe($productData['description']); + expect($product->price)->toBe($productData['price']); + expect($product->createdAt)->toBeInstanceOf(Date::class); + expect($product->pivot)->toBeInstanceOf(stdClass::class); + expect($product->pivot->product_id)->toBe(122); + expect($product->pivot->invoice_id)->toBe(20); + expect($product->pivot->quantity)->toBe(2); + expect($product->pivot->value)->toBe(100.0); +}); diff --git a/tests/Unit/Database/Models/Relationships/BelongsToManyTest.php b/tests/Unit/Database/Models/Relationships/BelongsToManyTest.php new file mode 100644 index 00000000..7ed0e590 --- /dev/null +++ b/tests/Unit/Database/Models/Relationships/BelongsToManyTest.php @@ -0,0 +1,35 @@ +withPivot(['amount', 'value']); + + expect($relationship->getColumns())->toBe([ + 'invoice_product.invoice_id' => 'pivot_invoice_id', + 'invoice_product.product_id' => 'pivot_product_id', + 'invoice_product.amount' => 'pivot_amount', + 'invoice_product.value' => 'pivot_value', + ]); +}); From ca73363910470b3a7b8c18348556aeb9ecbc42f7 Mon Sep 17 00:00:00 2001 From: Omar Barbosa Date: Wed, 20 Nov 2024 14:09:34 -0500 Subject: [PATCH 18/44] feat: load belongs to many relationship with column selection --- .../QueryBuilders/DatabaseQueryBuilder.php | 11 ++- tests/Feature/Database/DatabaseModelTest.php | 67 +++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php b/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php index 83928b30..a4eeabac 100644 --- a/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php +++ b/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php @@ -76,6 +76,15 @@ public function connection(SqlCommonConnectionPool|string $connection): self return $this; } + public function addSelect(array $columns): static + { + $this->action = Actions::SELECT; + + $this->columns = array_merge($this->columns, $columns); + + return $this; + } + public function paginate(Http $uri, int $defaultPage = 1, int $defaultPerPage = 15): Paginator { $this->action = Actions::SELECT; @@ -372,7 +381,7 @@ protected function resolveBelongsToManyRelationship( /** @var Collection $related */ $related = $relationship->query() - ->select($relationship->getColumns()) + ->addSelect($relationship->getColumns()) ->innerJoin($attr->table, function (Join $join) use ($attr): void { $join->onEqual( "{$this->model->getTable()}.{$this->model->getModelKeyName()}", diff --git a/tests/Feature/Database/DatabaseModelTest.php b/tests/Feature/Database/DatabaseModelTest.php index 71562927..ef7d0c29 100644 --- a/tests/Feature/Database/DatabaseModelTest.php +++ b/tests/Feature/Database/DatabaseModelTest.php @@ -467,3 +467,70 @@ expect($product->pivot->quantity)->toBe(2); expect($product->pivot->value)->toBe(100.0); }); + +it('loads relationship when the model belongs to many models with column selection', function () { + $invoiceData = [ + 'id' => 20, + 'reference' => '1234', + 'value' => 100.0, + 'created_at' => Date::now()->toDateTimeString(), + ]; + + $invoiceCollection[] = $invoiceData; + + $productData = [ + 'id' => 122, + 'description' => 'PHP Plush', + 'pivot_product_id' => 122, + 'pivot_invoice_id' => 20, + 'pivot_quantity' => 2, + 'pivot_value' => 100.0, + ]; + + $productCollection[] = $productData; + + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->exactly(2)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result($invoiceCollection)), + new Statement(new Result($productCollection)), + ); + + $this->app->swap(Connections::default(), $connection); + + /** @var Collection $invoices */ + $invoices = Invoice::query() + ->with([ + 'products' => function (BelongsToMany $relation) { + $relation->withPivot(['quantity', 'value']) + ->query() + ->select(['id', 'description']); + }, + ]) + ->get(); + + expect($invoices)->toBeInstanceOf(Collection::class); + expect($invoices->count())->toBe(1); + + expect($invoices->first()->id)->toBe($invoiceData['id']); + expect($invoices->first()->reference)->toBe($invoiceData['reference']); + expect($invoices->first()->value)->toBe($invoiceData['value']); + + expect($invoices->first()->products)->toBeInstanceOf(Collection::class); + expect($invoices->first()->products->count())->toBe(1); + + /** @var Product $product */ + $product = $invoices->first()->products->first(); + + expect($product->id)->toBe($productData['id']); + expect($product->description)->toBe($productData['description']); + expect(isset($product->price))->toBeFalse(); + expect(isset($product->createdAt))->toBeFalse(); + expect($product->pivot)->toBeInstanceOf(stdClass::class); + expect($product->pivot->product_id)->toBe(122); + expect($product->pivot->invoice_id)->toBe(20); + expect($product->pivot->quantity)->toBe(2); + expect($product->pivot->value)->toBe(100.0); +}); From 27d470c35cfbb86f589c633b3c978a34372ace11 Mon Sep 17 00:00:00 2001 From: Omar Barbosa Date: Wed, 20 Nov 2024 14:38:43 -0500 Subject: [PATCH 19/44] feat: shot syntax to select relationship columns --- .../QueryBuilders/DatabaseQueryBuilder.php | 19 ++++++- tests/Feature/Database/DatabaseModelTest.php | 49 +++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php b/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php index a4eeabac..74332033 100644 --- a/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php +++ b/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php @@ -30,6 +30,7 @@ use function array_key_exists; use function is_string; +use function str_contains; class DatabaseQueryBuilder extends QueryBase { @@ -206,8 +207,9 @@ public function with(array|string $relationships): self $relationshipName = $key; $closure = $value; } else { - $relationshipName = $value; - $closure = fn ($builder) => $builder->query(); + [$relationshipName, $columns] = $this->parseRelationship($value); + + $closure = fn ($builder) => $builder->query()->select($columns); } $relationship = $modelRelationships[$relationshipName] ?? null; @@ -222,6 +224,19 @@ public function with(array|string $relationships): self return $this; } + protected function parseRelationship(string $relationshipName): array + { + if (str_contains($relationshipName, ':')) { + [$relation, $columns] = explode(':', $relationshipName); + + $columns = explode(',', $columns); + + return [$relation, $columns]; + } + + return [$relationshipName, ['*']]; + } + /** * @return Collection */ diff --git a/tests/Feature/Database/DatabaseModelTest.php b/tests/Feature/Database/DatabaseModelTest.php index ef7d0c29..b2db009d 100644 --- a/tests/Feature/Database/DatabaseModelTest.php +++ b/tests/Feature/Database/DatabaseModelTest.php @@ -105,6 +105,55 @@ expect($post->user->email)->toBe($userData['email']); }); +it('loads relationship with short syntax to select columns', function () { + $userData = [ + 'id' => 1, + 'name' => 'John Doe', + ]; + + $userCollection[] = $userData; + + $postData = [ + 'id' => 1, + 'title' => 'PHP is great', + 'content' => faker()->sentence(), + 'user_id' => $userData['id'], + 'created_at' => Date::now()->toDateTimeString(), + ]; + + $postCollection[] = $postData; + + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->exactly(2)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result($postCollection)), + new Statement(new Result($userCollection)), + ); + + $this->app->swap(Connections::default(), $connection); + + /** @var Post $post */ + $post = Post::query()->selectAllColumns() + ->with('user:id,name') + ->first(); + + expect($post)->toBeInstanceOf(Post::class); + + expect($post->id)->toBe($postData['id']); + expect($post->title)->toBe($postData['title']); + expect($post->content)->toBe($postData['content']); + expect($post->createdAt)->toBeInstanceOf(Date::class); + expect($post->updatedAt)->toBeNull(); + + expect($post->user)->toBeInstanceOf(User::class); + + expect($post->user->id)->toBe($userData['id']); + expect($post->user->name)->toBe($userData['name']); + expect(isset($post->user->email))->toBeFalse(); +}); + it('loads relationship when the model belongs to a parent model with column selection', function () { $userData = [ 'id' => 1, From 3cdb1504c4f5e3f2f688d6043ff1e7c2cf3b588e Mon Sep 17 00:00:00 2001 From: Omar Barbosa Date: Tue, 10 Dec 2024 18:12:45 -0500 Subject: [PATCH 20/44] feat: relationship parser --- .../Relationships/RelationshipParser.php | 87 ++++++++++++++ .../Relationships/RelationshipParserTest.php | 110 ++++++++++++++++++ 2 files changed, 197 insertions(+) create mode 100644 src/Database/Models/Relationships/RelationshipParser.php create mode 100644 tests/Unit/Database/Models/Relationships/RelationshipParserTest.php diff --git a/src/Database/Models/Relationships/RelationshipParser.php b/src/Database/Models/Relationships/RelationshipParser.php new file mode 100644 index 00000000..5a0b2ba0 --- /dev/null +++ b/src/Database/Models/Relationships/RelationshipParser.php @@ -0,0 +1,87 @@ +mappedRelationships = []; + } + + public function parse(): self + { + $this->mappedRelationships = $this->parseRelations($this->relationships); + + return $this; + } + + public function toArray(): array + { + return $this->mappedRelationships; + } + + protected function parseRelations(array $unpreparedRelations, string|null $parent = null): array + { + $relations = []; + + foreach ($unpreparedRelations as $key => $value) { + [$name, $data] = $this->parseRelation($key, $value); + + $relations[$name] = $data; + } + + return $relations; + } + + protected function parseRelation(int|string $key, Closure|string|null $value = null, string|null $parent = null): array + { + $columns = null; + + if ($value instanceof Closure) { + $relationKey = $key; + $columns = $value; + } else { + $relationKey = $value; + } + + $relationships = []; + + [$name, $relation, $columns] = $this->parseKey($relationKey, $columns); + + if ($relation) { + [$nextRelationName, $data] = $this->parseRelation(0, $relation); + + $relationships[$nextRelationName] = $data; + } + + return [ + $name, + [ + 'columns' => $columns, + 'relationships' => $relationships, + ], + ]; + } + + protected function parseKey(string $relationshipKey, Closure|null $closure): array + { + $relations = explode('.', $relationshipKey); + + $name = array_shift($relations); + + return [ + $name, + implode('.', $relations), + $closure instanceof Closure ? $closure : ['*'], + ]; + } +} diff --git a/tests/Unit/Database/Models/Relationships/RelationshipParserTest.php b/tests/Unit/Database/Models/Relationships/RelationshipParserTest.php new file mode 100644 index 00000000..f7870335 --- /dev/null +++ b/tests/Unit/Database/Models/Relationships/RelationshipParserTest.php @@ -0,0 +1,110 @@ +parse(); + + expect($parser->toArray())->toBe([ + 'user' => [ + 'columns' => ['*'], + 'relationships' => [], + ], + ]); +}); + +it('parse single relationship with closure', function () { + $closure = function (BelongsTo $belongsTo) { + $belongsTo->query() + ->select(['id', 'name']); + }; + + $parser = new RelationshipParser([ + 'user' => $closure, + ]); + + $parser->parse(); + + expect($parser->toArray())->toBe([ + 'user' => [ + 'columns' => $closure, + 'relationships' => [], + ], + ]); +}); + +it('parse multiple relationships', function () { + $parser = new RelationshipParser([ + 'user', + 'posts', + ]); + + $parser->parse(); + + expect($parser->toArray())->toBe([ + 'user' => [ + 'columns' => ['*'], + 'relationships' => [], + ], + 'posts' => [ + 'columns' => ['*'], + 'relationships' => [], + ], + ]); +}); + +it('parse relationships with dot notation in second level', function () { + $parser = new RelationshipParser([ + 'user.company', + ]); + + $parser->parse(); + + expect($parser->toArray())->toBe([ + 'user' => [ + 'columns' => ['*'], + 'relationships' => [ + 'company' => [ + 'columns' => ['*'], + 'relationships' => [], + ], + ], + ], + ]); +}); + +it('parse relationships with dot notation in nested level', function () { + $parser = new RelationshipParser([ + 'user', + 'user.company', + 'user.company.account', + ]); + + $parser->parse(); + + dump($parser->toArray()); + + expect($parser->toArray())->toBe([ + 'user' => [ + 'columns' => ['*'], + 'relationships' => [ + 'company' => [ + 'columns' => ['*'], + 'relationships' => [ + 'account' => [ + 'columns' => ['*'], + 'relationships' => [], + ], + ], + ], + ], + ], + ]); +}); From 017bd4cc532c53035361862ed29f92b05a3faf76 Mon Sep 17 00:00:00 2001 From: Omar Barbosa Date: Tue, 10 Dec 2024 18:39:38 -0500 Subject: [PATCH 21/44] feat: split columns from relationship name --- .../Models/Relationships/RelationshipParser.php | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/Database/Models/Relationships/RelationshipParser.php b/src/Database/Models/Relationships/RelationshipParser.php index 5a0b2ba0..a870ef8f 100644 --- a/src/Database/Models/Relationships/RelationshipParser.php +++ b/src/Database/Models/Relationships/RelationshipParser.php @@ -19,7 +19,7 @@ public function __construct( public function parse(): self { - $this->mappedRelationships = $this->parseRelations($this->relationships); + $this->mappedRelationships = $this->parseRelations(); return $this; } @@ -29,11 +29,11 @@ public function toArray(): array return $this->mappedRelationships; } - protected function parseRelations(array $unpreparedRelations, string|null $parent = null): array + protected function parseRelations(string|null $parent = null): array { $relations = []; - foreach ($unpreparedRelations as $key => $value) { + foreach ($this->relationships as $key => $value) { [$name, $data] = $this->parseRelation($key, $value); $relations[$name] = $data; @@ -77,11 +77,18 @@ protected function parseKey(string $relationshipKey, Closure|null $closure): arr $relations = explode('.', $relationshipKey); $name = array_shift($relations); + $columns = ['*']; + + if (str_contains($name, ':')) { + [$name, $columns] = explode(':', $name); + + $columns = explode(',', $columns); + } return [ $name, implode('.', $relations), - $closure instanceof Closure ? $closure : ['*'], + $closure instanceof Closure ? $closure : $columns, ]; } } From c590dd3d14780fdda7bda258526ca00a540ab663 Mon Sep 17 00:00:00 2001 From: Omar Barbosa Date: Wed, 11 Dec 2024 08:02:04 -0500 Subject: [PATCH 22/44] refactor: remove parent param --- src/Database/Models/Relationships/RelationshipParser.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Database/Models/Relationships/RelationshipParser.php b/src/Database/Models/Relationships/RelationshipParser.php index a870ef8f..d429c803 100644 --- a/src/Database/Models/Relationships/RelationshipParser.php +++ b/src/Database/Models/Relationships/RelationshipParser.php @@ -29,7 +29,7 @@ public function toArray(): array return $this->mappedRelationships; } - protected function parseRelations(string|null $parent = null): array + protected function parseRelations(): array { $relations = []; From 7b3aca6f472532653cf86a1887d73d78116801ec Mon Sep 17 00:00:00 2001 From: Omar Barbosa Date: Wed, 11 Dec 2024 19:29:53 -0500 Subject: [PATCH 23/44] feat: parse nested relationship --- .../Relationships/RelationshipParser.php | 70 ++++++------------- .../Relationships/RelationshipParserTest.php | 31 +++++++- 2 files changed, 52 insertions(+), 49 deletions(-) diff --git a/src/Database/Models/Relationships/RelationshipParser.php b/src/Database/Models/Relationships/RelationshipParser.php index d429c803..27676f60 100644 --- a/src/Database/Models/Relationships/RelationshipParser.php +++ b/src/Database/Models/Relationships/RelationshipParser.php @@ -34,61 +34,37 @@ protected function parseRelations(): array $relations = []; foreach ($this->relationships as $key => $value) { - [$name, $data] = $this->parseRelation($key, $value); + $columns = ['*']; - $relations[$name] = $data; - } - - return $relations; - } + if ($value instanceof Closure) { + $relationKey = $key; + $columns = $value; + } else { + $relationKey = $value; + } - protected function parseRelation(int|string $key, Closure|string|null $value = null, string|null $parent = null): array - { - $columns = null; + $current = &$relations; - if ($value instanceof Closure) { - $relationKey = $key; - $columns = $value; - } else { - $relationKey = $value; - } + $keys = explode('.', $relationKey); - $relationships = []; + foreach ($keys as $relationName) { + if (!isset($current[$relationName])) { + if (str_contains($relationName, ':')) { + [$relationName, $columns] = explode(':', $relationName); - [$name, $relation, $columns] = $this->parseKey($relationKey, $columns); + $columns = explode(',', $columns); + } - if ($relation) { - [$nextRelationName, $data] = $this->parseRelation(0, $relation); + $current[$relationName] = [ + 'columns' => $columns, + 'relationships' => [], + ]; + } - $relationships[$nextRelationName] = $data; + $current = &$current[$relationName]['relationships']; + } } - return [ - $name, - [ - 'columns' => $columns, - 'relationships' => $relationships, - ], - ]; - } - - protected function parseKey(string $relationshipKey, Closure|null $closure): array - { - $relations = explode('.', $relationshipKey); - - $name = array_shift($relations); - $columns = ['*']; - - if (str_contains($name, ':')) { - [$name, $columns] = explode(':', $name); - - $columns = explode(',', $columns); - } - - return [ - $name, - implode('.', $relations), - $closure instanceof Closure ? $closure : $columns, - ]; + return $relations; } } diff --git a/tests/Unit/Database/Models/Relationships/RelationshipParserTest.php b/tests/Unit/Database/Models/Relationships/RelationshipParserTest.php index f7870335..35b99316 100644 --- a/tests/Unit/Database/Models/Relationships/RelationshipParserTest.php +++ b/tests/Unit/Database/Models/Relationships/RelationshipParserTest.php @@ -89,8 +89,6 @@ $parser->parse(); - dump($parser->toArray()); - expect($parser->toArray())->toBe([ 'user' => [ 'columns' => ['*'], @@ -108,3 +106,32 @@ ], ]); }); + +it('parse relationships with dot notation in nested level with column selection', function () { + $parser = new RelationshipParser([ + 'user:id,name,company_id', + 'user.company:id,name,account_id', + 'user.company.account:id,name', + ]); + + $parser->parse(); + + dump($parser->toArray()); + + expect($parser->toArray())->toBe([ + 'user' => [ + 'columns' => ['id', 'name', 'company_id'], + 'relationships' => [ + 'company' => [ + 'columns' => ['id', 'name', 'account_id'], + 'relationships' => [ + 'account' => [ + 'columns' => ['id', 'name'], + 'relationships' => [], + ], + ], + ], + ], + ], + ]); +}); From d30212eea26e81504972c256b583282b234d4033 Mon Sep 17 00:00:00 2001 From: Omar Barbosa Date: Thu, 12 Dec 2024 08:02:12 -0500 Subject: [PATCH 24/44] feat: load nested relationships --- .../QueryBuilders/DatabaseQueryBuilder.php | 34 +++------- .../Relationships/RelationshipParser.php | 28 ++++----- tests/Feature/Database/DatabaseModelTest.php | 62 +++++++++++++++++++ tests/Feature/Database/Models/Comment.php | 7 +++ tests/Feature/Database/Models/Product.php | 4 ++ .../Relationships/RelationshipParserTest.php | 29 ++------- 6 files changed, 102 insertions(+), 62 deletions(-) diff --git a/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php b/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php index 74332033..45d5e30b 100644 --- a/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php +++ b/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php @@ -23,14 +23,15 @@ use Phenix\Database\Models\Relationships\BelongsToMany; use Phenix\Database\Models\Relationships\HasMany; use Phenix\Database\Models\Relationships\Relationship; +use Phenix\Database\Models\Relationships\RelationshipParser; use Phenix\Database\Paginator; use Phenix\Database\QueryBase; use Phenix\Exceptions\Database\ModelException; use Phenix\Util\Arr; use function array_key_exists; +use function is_array; use function is_string; -use function str_contains; class DatabaseQueryBuilder extends QueryBase { @@ -198,19 +199,17 @@ public function setModel(DatabaseModel $model): self public function with(array|string $relationships): self { - $relationships = (array) $relationships; - $modelRelationships = $this->model->getRelationshipBindings(); - foreach ($relationships as $key => $value) { - if ($value instanceof Closure) { - $relationshipName = $key; - $closure = $value; - } else { - [$relationshipName, $columns] = $this->parseRelationship($value); + $relationshipParser = new RelationshipParser((array) $relationships); + $relationshipParser->parse(); - $closure = fn ($builder) => $builder->query()->select($columns); - } + foreach ($relationshipParser->toArray() as $relationshipName => $relationshipData) { + ['columns' => $columns, 'relationships' => $relations] = $relationshipData; + + $closure = is_array($columns) + ? fn ($builder) => $builder->query()->select($columns)->with($relations) + : $columns; $relationship = $modelRelationships[$relationshipName] ?? null; @@ -224,19 +223,6 @@ public function with(array|string $relationships): self return $this; } - protected function parseRelationship(string $relationshipName): array - { - if (str_contains($relationshipName, ':')) { - [$relation, $columns] = explode(':', $relationshipName); - - $columns = explode(',', $columns); - - return [$relation, $columns]; - } - - return [$relationshipName, ['*']]; - } - /** * @return Collection */ diff --git a/src/Database/Models/Relationships/RelationshipParser.php b/src/Database/Models/Relationships/RelationshipParser.php index 27676f60..9206b184 100644 --- a/src/Database/Models/Relationships/RelationshipParser.php +++ b/src/Database/Models/Relationships/RelationshipParser.php @@ -6,6 +6,7 @@ use Closure; use Phenix\Contracts\Arrayable; +use Phenix\Util\Arr; class RelationshipParser implements Arrayable { @@ -43,25 +44,24 @@ protected function parseRelations(): array $relationKey = $value; } - $current = &$relations; - $keys = explode('.', $relationKey); + $relationName = array_shift($keys); - foreach ($keys as $relationName) { - if (!isset($current[$relationName])) { - if (str_contains($relationName, ':')) { - [$relationName, $columns] = explode(':', $relationName); + if (str_contains($relationName, ':')) { + [$relationName, $columns] = explode(':', $relationName); - $columns = explode(',', $columns); - } + $columns = explode(',', $columns); + } - $current[$relationName] = [ - 'columns' => $columns, - 'relationships' => [], - ]; - } + $keys = empty($keys) ? [] : Arr::wrap(implode('.', $keys)); - $current = &$current[$relationName]['relationships']; + if (isset($relations[$relationName])) { + $relations[$relationName]['relationships'] = array_merge($relations[$relationName]['relationships'], $keys); + } else { + $relations[$relationName] = [ + 'columns' => $columns, + 'relationships' => $keys, + ]; } } diff --git a/tests/Feature/Database/DatabaseModelTest.php b/tests/Feature/Database/DatabaseModelTest.php index b2db009d..ac3138d1 100644 --- a/tests/Feature/Database/DatabaseModelTest.php +++ b/tests/Feature/Database/DatabaseModelTest.php @@ -4,6 +4,7 @@ use Phenix\Database\Constants\Connections; use Phenix\Database\Models\Collection; +use Phenix\Database\Models\DatabaseModel; use Phenix\Database\Models\Relationships\BelongsTo; use Phenix\Database\Models\Relationships\BelongsToMany; use Phenix\Database\Models\Relationships\HasMany; @@ -583,3 +584,64 @@ expect($product->pivot->quantity)->toBe(2); expect($product->pivot->value)->toBe(100.0); }); + +it('loads nested relationship using dot notation', function () { + $userData = [ + 'id' => 1, + 'name' => 'John Doe', + 'email' => 'john.doe@email.com', + 'created_at' => Date::now()->toDateTimeString(), + ]; + + $productData = [ + 'id' => 1, + 'description' => 'Phenix shirt', + 'price' => 100, + 'stock' => 6, + 'user_id' => $userData['id'], + 'created_at' => Date::now()->toDateTimeString(), + ]; + + $commentData = [ + 'id' => 1, + 'content' => 'PHP is awesome', + 'product_id' => $productData['id'], + 'created_at' => Date::now()->toDateTimeString(), + ]; + + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->exactly(3)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result([$commentData])), + new Statement(new Result([$productData])), + new Statement(new Result([$userData])), + ); + + $this->app->swap(Connections::default(), $connection); + + /** @var Comment $comment */ + $comment = Comment::query() + ->with([ + 'product:id,description,price,stock,user_id,created_at', + 'product.user:id,name,email,created_at', + ]) + ->first(); + + expect($comment)->toBeInstanceOf(DatabaseModel::class); + + expect($comment->id)->toBe($commentData['id']); + expect($comment->content)->toBe($commentData['content']); + expect($comment->createdAt)->toBeInstanceOf(Date::class); + expect($comment->productId)->toBe($productData['id']); + + expect($comment->product)->toBeInstanceOf(DatabaseModel::class); + expect($comment->product->id)->toBe($productData['id']); + expect($comment->product->description)->toBe($productData['description']); + + expect($comment->product->user)->toBeInstanceOf(DatabaseModel::class); + expect($comment->product->user->id)->toBe($userData['id']); + expect($comment->product->user->name)->toBe($userData['name']); + expect($comment->product->user->email)->toBe($userData['email']); +}); diff --git a/tests/Feature/Database/Models/Comment.php b/tests/Feature/Database/Models/Comment.php index 8b7ff1c1..b6352114 100644 --- a/tests/Feature/Database/Models/Comment.php +++ b/tests/Feature/Database/Models/Comment.php @@ -22,6 +22,10 @@ class Comment extends DatabaseModel #[ForeignKey(name: 'user_id')] public int $userId; + + #[ForeignKey(name: 'product_id')] + public int $productId; + #[Column(name: 'created_at')] public Date $createdAt; @@ -31,6 +35,9 @@ class Comment extends DatabaseModel #[BelongsTo('userId')] public User $user; + #[BelongsTo('productId')] + public Product $product; + public static function table(): string { return 'comments'; diff --git a/tests/Feature/Database/Models/Product.php b/tests/Feature/Database/Models/Product.php index c92db7f3..45ad798c 100644 --- a/tests/Feature/Database/Models/Product.php +++ b/tests/Feature/Database/Models/Product.php @@ -8,6 +8,7 @@ use Phenix\Database\Models\Attributes\BelongsToMany; use Phenix\Database\Models\Attributes\Column; use Phenix\Database\Models\Attributes\ForeignKey; +use Phenix\Database\Models\Attributes\HasMany; use Phenix\Database\Models\Attributes\Id; use Phenix\Database\Models\Collection; use Phenix\Database\Models\DatabaseModel; @@ -39,6 +40,9 @@ class Product extends DatabaseModel #[BelongsTo('userId')] public User $user; + #[HasMany(model: Comment::class, foreignKey: 'product_id')] + public Collection $comments; + #[BelongsToMany( table: 'invoice_product', foreignKey: 'product_id', diff --git a/tests/Unit/Database/Models/Relationships/RelationshipParserTest.php b/tests/Unit/Database/Models/Relationships/RelationshipParserTest.php index 35b99316..d4731066 100644 --- a/tests/Unit/Database/Models/Relationships/RelationshipParserTest.php +++ b/tests/Unit/Database/Models/Relationships/RelationshipParserTest.php @@ -71,10 +71,7 @@ 'user' => [ 'columns' => ['*'], 'relationships' => [ - 'company' => [ - 'columns' => ['*'], - 'relationships' => [], - ], + 'company', ], ], ]); @@ -93,15 +90,8 @@ 'user' => [ 'columns' => ['*'], 'relationships' => [ - 'company' => [ - 'columns' => ['*'], - 'relationships' => [ - 'account' => [ - 'columns' => ['*'], - 'relationships' => [], - ], - ], - ], + 'company', + 'company.account', ], ], ]); @@ -116,21 +106,12 @@ $parser->parse(); - dump($parser->toArray()); - expect($parser->toArray())->toBe([ 'user' => [ 'columns' => ['id', 'name', 'company_id'], 'relationships' => [ - 'company' => [ - 'columns' => ['id', 'name', 'account_id'], - 'relationships' => [ - 'account' => [ - 'columns' => ['id', 'name'], - 'relationships' => [], - ], - ], - ], + 'company:id,name,account_id', + 'company.account:id,name', ], ], ]); From f4ec8434ebe77811e6fc9e5d92893c8695d56686 Mon Sep 17 00:00:00 2001 From: Omar Barbosa Date: Thu, 12 Dec 2024 08:16:20 -0500 Subject: [PATCH 25/44] feat: add array method --- src/Util/Arr.php | 133 ++++++++++++++++++++++++++++++++++++++++++---- src/functions.php | 7 +++ 2 files changed, 129 insertions(+), 11 deletions(-) diff --git a/src/Util/Arr.php b/src/Util/Arr.php index 5527fffe..962c9eb4 100644 --- a/src/Util/Arr.php +++ b/src/Util/Arr.php @@ -4,6 +4,7 @@ namespace Phenix\Util; +use ArrayAccess; use Closure; use function array_filter; @@ -11,6 +12,11 @@ use function is_array; use function is_null; +/** + * Laravel team credits + * + * @see https://github.com/laravel/framework/blob/master/src/Illuminate/Collections/Arr.php + */ class Arr extends Utility { /** @@ -52,26 +58,25 @@ public static function every(array $definition, Closure $closure): bool public static function first(array $data, Closure|null $closure = null, mixed $default = null): mixed { + $result = $default; + if ($closure) { foreach ($data as $key => $value) { if ($closure($value, $key)) { - return $value; + $result = $value; + + break; } } - - return $default; + } elseif (array_is_list($data)) { + $result = $data[0] ?? $default; + } else { + $result = array_values($data)[0] ?? $default; } - if (array_is_list($data)) { - return $data[0] ?? $default; - } - - return array_values($data)[0] ?? $default; + return $result; } - /** - * Laravel team credits - */ public static function wrap(mixed $value): array { if (is_null($value)) { @@ -80,4 +85,110 @@ public static function wrap(mixed $value): array return is_array($value) ? $value : [$value]; } + + public static function set(array &$array, string|int $key, mixed $value): array + { + $keys = explode('.', $key); + + foreach ($keys as $i => $key) { + if (count($keys) === 1) { + break; + } + + unset($keys[$i]); + + if (! isset($array[$key]) || ! is_array($array[$key])) { + $array[$key] = []; + } + + $array = &$array[$key]; + } + + $array[array_shift($keys)] = $value; + + return $array; + } + + public static function undot(array $array): array + { + $results = []; + + foreach ($array as $key => $value) { + static::set($results, $key, $value); + } + + return $results; + } + + public static function has(ArrayAccess|array $array, string|array $keys): bool + { + $keys = (array) $keys; + + if (! $array || $keys === []) { + return false; + } + + foreach ($keys as $key) { + $subKeyArray = $array; + + if (static::exists($array, $key)) { + continue; + } + + foreach (explode('.', $key) as $segment) { + if (static::accessible($subKeyArray) && static::exists($subKeyArray, $segment)) { + $subKeyArray = $subKeyArray[$segment]; + } else { + return false; + } + } + } + + return true; + } + + public static function exists(ArrayAccess|array $array, string|int $key): bool + { + if ($array instanceof ArrayAccess) { + return $array->offsetExists($key); + } + + if (is_float($key)) { + $key = (string) $key; + } + + return array_key_exists($key, $array); + } + + public static function accessible(mixed $value): bool + { + return is_array($value) || $value instanceof ArrayAccess; + } + + public static function get(ArrayAccess|array $array, string|int|null $key, mixed $default = null): mixed + { + $result = $default; + + if (static::accessible($array)) { + if ($key === null) { + $result = $array; + } elseif (static::exists($array, $key)) { + $result = $array[$key]; + } elseif (!str_contains($key, '.')) { + $result = $array[$key] ?? value($default); + } else { + foreach (explode('.', $key) as $segment) { + if (static::accessible($array) && static::exists($array, $segment)) { + $array = $array[$segment]; + $result = $array; + } else { + $result = value($default); + break; + } + } + } + } + + return $result; + } } diff --git a/src/functions.php b/src/functions.php index 39673c56..0ffdb04d 100644 --- a/src/functions.php +++ b/src/functions.php @@ -37,3 +37,10 @@ function env(string $key, Closure|null $default = null): array|string|int|bool|n return $default instanceof Closure ? $default() : $default; } } + +if (! function_exists('value')) { + function value($value, ...$args) + { + return $value instanceof Closure ? $value(...$args) : $value; + } +} From f5254f4a411dae66ec96558751e7f4e500416e83 Mon Sep 17 00:00:00 2001 From: Omar Barbosa Date: Thu, 12 Dec 2024 08:24:09 -0500 Subject: [PATCH 26/44] refactor: import functions --- src/Util/Arr.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/Util/Arr.php b/src/Util/Arr.php index 962c9eb4..772c6b51 100644 --- a/src/Util/Arr.php +++ b/src/Util/Arr.php @@ -7,10 +7,20 @@ use ArrayAccess; use Closure; +use function array_combine; use function array_filter; +use function array_is_list; +use function array_key_exists; +use function array_keys; +use function array_map; +use function array_shift; +use function array_values; +use function explode; use function implode; use function is_array; +use function is_float; use function is_null; +use function str_contains; /** * Laravel team credits From 030d480550ed8d5f9a9d6533be83fe83f8b9d885 Mon Sep 17 00:00:00 2001 From: Omar Barbosa Date: Thu, 12 Dec 2024 08:28:12 -0500 Subject: [PATCH 27/44] chore: remove comments --- src/Database/Models/DatabaseModel.php | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/Database/Models/DatabaseModel.php b/src/Database/Models/DatabaseModel.php index f4a43c0b..b1672922 100644 --- a/src/Database/Models/DatabaseModel.php +++ b/src/Database/Models/DatabaseModel.php @@ -218,11 +218,4 @@ protected function findModelKey(): ModelProperty return $property->getAttribute() instanceof Id; }); } - - // Relationships - - // API: save, delete, update, updateOr, first, alone, firstOr, get, cursor, paginate - // Static API: Create, find, findOr - - // Model config feature } From 8a266801b7fd498c33fcaf663cd7fe47e0172944 Mon Sep 17 00:00:00 2001 From: Omar Barbosa Date: Thu, 12 Dec 2024 12:21:51 -0500 Subject: [PATCH 28/44] fix: solve phpstan errors --- src/Database/Models/DatabaseModel.php | 1 + src/Util/Arr.php | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Database/Models/DatabaseModel.php b/src/Database/Models/DatabaseModel.php index b1672922..d7969fbc 100644 --- a/src/Database/Models/DatabaseModel.php +++ b/src/Database/Models/DatabaseModel.php @@ -31,6 +31,7 @@ use function array_map; use function array_shift; +/** @phpstan-consistent-constructor */ abstract class DatabaseModel implements Arrayable { protected string $table; diff --git a/src/Util/Arr.php b/src/Util/Arr.php index 772c6b51..f77c3cae 100644 --- a/src/Util/Arr.php +++ b/src/Util/Arr.php @@ -163,7 +163,7 @@ public static function exists(ArrayAccess|array $array, string|int $key): bool return $array->offsetExists($key); } - if (is_float($key)) { + if (is_int($key)) { $key = (string) $key; } From ee87ca2b53fd45267683a5d2071ba97007ff754c Mon Sep 17 00:00:00 2001 From: Omar Barbosa Date: Thu, 12 Dec 2024 12:22:45 -0500 Subject: [PATCH 29/44] fix: phpstan errors --- src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php b/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php index 45d5e30b..f06a79ea 100644 --- a/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php +++ b/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php @@ -302,7 +302,7 @@ protected function resolveRelationships(Collection $collection): void /** * @param Collection $models * @param BelongsTo $relationship - * @param Closure|null $closure + * @param Closure $closure */ protected function resolveBelongsToRelationship( Collection $models, @@ -330,6 +330,7 @@ protected function resolveBelongsToRelationship( /** * @param Collection $models * @param HasMany $relationship + * @param Closure $closure */ protected function resolveHasManyRelationship( Collection $models, @@ -370,6 +371,7 @@ protected function resolveHasManyRelationship( /** * @param Collection $models * @param BelongsToMany $relationship + * @param Closure $closure */ protected function resolveBelongsToManyRelationship( Collection $models, From 785bcf01f44fceec0ca67c730e46cd8da55f246a Mon Sep 17 00:00:00 2001 From: Omar Barbosa Date: Thu, 12 Dec 2024 15:23:05 -0500 Subject: [PATCH 30/44] fix: phpstan issues --- src/Database/Models/DatabaseModel.php | 5 ++-- .../Properties/BelongsToManyProperty.php | 3 +++ .../Models/Properties/BelongsToProperty.php | 3 +++ .../Models/Properties/HasManyProperty.php | 3 +++ .../Models/Properties/ModelProperty.php | 2 +- .../Properties/ModelPropertyInterface.php | 24 +++++++++++++++++++ src/Util/Arr.php | 4 ++-- 7 files changed, 39 insertions(+), 5 deletions(-) create mode 100644 src/Database/Models/Properties/ModelPropertyInterface.php diff --git a/src/Database/Models/DatabaseModel.php b/src/Database/Models/DatabaseModel.php index d7969fbc..6e314452 100644 --- a/src/Database/Models/DatabaseModel.php +++ b/src/Database/Models/DatabaseModel.php @@ -7,6 +7,7 @@ use Phenix\Contracts\Arrayable; use Phenix\Database\Models\Attributes\BelongsTo as BelongsToAttribute; use Phenix\Database\Models\Attributes\BelongsToMany as BelongsToManyAttribute; +use Phenix\Database\Models\Attributes\Column; use Phenix\Database\Models\Attributes\HasMany as HasManyAttribute; use Phenix\Database\Models\Attributes\Id; use Phenix\Database\Models\Attributes\ModelAttribute; @@ -149,7 +150,7 @@ protected function buildPropertyBindings(): array return $attr->newInstance(); }, $property->getAttributes()); - /** @var array $attributes */ + /** @var array $attributes */ $attributes = array_filter($attributes, fn (object $attr) => $attr instanceof ModelAttribute); if (empty($attributes)) { @@ -182,7 +183,7 @@ protected function buildRelationshipBindings(): array return $relationships; } - protected function buildModelProperty(ModelAttribute $attribute, ReflectionProperty $property): ModelProperty + protected function buildModelProperty(ModelAttribute&Column $attribute, ReflectionProperty $property): ModelProperty { $arguments = [ $property->getName(), diff --git a/src/Database/Models/Properties/BelongsToManyProperty.php b/src/Database/Models/Properties/BelongsToManyProperty.php index 362a3c7f..c9c02ca0 100644 --- a/src/Database/Models/Properties/BelongsToManyProperty.php +++ b/src/Database/Models/Properties/BelongsToManyProperty.php @@ -7,6 +7,9 @@ use Phenix\Database\Models\Attributes\BelongsToMany; use Phenix\Database\Models\QueryBuilders\DatabaseQueryBuilder; +/** + * @property BelongsToMany $attribute + */ class BelongsToManyProperty extends ModelProperty { public function getAttribute(): BelongsToMany diff --git a/src/Database/Models/Properties/BelongsToProperty.php b/src/Database/Models/Properties/BelongsToProperty.php index 5d999cad..66997957 100644 --- a/src/Database/Models/Properties/BelongsToProperty.php +++ b/src/Database/Models/Properties/BelongsToProperty.php @@ -7,6 +7,9 @@ use Phenix\Database\Models\Attributes\BelongsTo; use Phenix\Database\Models\QueryBuilders\DatabaseQueryBuilder; +/** + * @property BelongsTo $attribute + */ class BelongsToProperty extends ModelProperty { public function getAttribute(): BelongsTo diff --git a/src/Database/Models/Properties/HasManyProperty.php b/src/Database/Models/Properties/HasManyProperty.php index 0f6b2257..570844f7 100644 --- a/src/Database/Models/Properties/HasManyProperty.php +++ b/src/Database/Models/Properties/HasManyProperty.php @@ -7,6 +7,9 @@ use Phenix\Database\Models\Attributes\HasMany; use Phenix\Database\Models\QueryBuilders\DatabaseQueryBuilder; +/** + * @property HasMany $attribute + */ class HasManyProperty extends ModelProperty { public function getAttribute(): HasMany diff --git a/src/Database/Models/Properties/ModelProperty.php b/src/Database/Models/Properties/ModelProperty.php index 190d51b8..9767fef6 100644 --- a/src/Database/Models/Properties/ModelProperty.php +++ b/src/Database/Models/Properties/ModelProperty.php @@ -9,7 +9,7 @@ use function is_null; -class ModelProperty +class ModelProperty implements ModelPropertyInterface { public function __construct( protected string $name, diff --git a/src/Database/Models/Properties/ModelPropertyInterface.php b/src/Database/Models/Properties/ModelPropertyInterface.php new file mode 100644 index 00000000..0138f0fb --- /dev/null +++ b/src/Database/Models/Properties/ModelPropertyInterface.php @@ -0,0 +1,24 @@ + Date: Thu, 12 Dec 2024 17:35:30 -0500 Subject: [PATCH 31/44] test: arr utility --- tests/Unit/Util/ArrTest.php | 54 +++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 tests/Unit/Util/ArrTest.php diff --git a/tests/Unit/Util/ArrTest.php b/tests/Unit/Util/ArrTest.php new file mode 100644 index 00000000..1a1c7ccf --- /dev/null +++ b/tests/Unit/Util/ArrTest.php @@ -0,0 +1,54 @@ + 'John', + 'age' => 30, + 'address' => [ + 'city' => 'New York', + 'zip' => '10001' + ] + ]; + expect(Arr::get($array, 'name'))->toBe('John'); + expect(Arr::get($array, 'age'))->toBe(30); + expect(Arr::get($array, 'address.city'))->toBe('New York'); + expect(Arr::get($array, 'nonexistent_key'))->toBeNull(); + expect(Arr::get($array, 'nonexistent_key.dotted'))->toBeNull(); +}); + +it('can set a value in an array', function () { + $array = ['name' => 'John']; + Arr::set($array, 'age', 30); + expect($array['age'])->toBe(30); +}); + +it('can check if an array has a key', function () { + $array = ['name' => 'John', 'age' => 30]; + expect(Arr::has($array, 'name'))->toBeTrue(); + expect(Arr::has($array, 'nonexistent_key'))->toBeFalse(); +}); + +it('can undot an array', function () { + $array = [ + 'user.name' => 'John', + 'user.age' => 30, + 'address.city' => 'New York', + 'address.zip' => '10001' + ]; + $expected = [ + 'user' => [ + 'name' => 'John', + 'age' => 30 + ], + 'address' => [ + 'city' => 'New York', + 'zip' => '10001' + ] + ]; + expect(Arr::undot($array))->toBe($expected); +}); + From be90df37af74786628fcabc055bf9ec37dd28fbc Mon Sep 17 00:00:00 2001 From: Omar Barbosa Date: Thu, 12 Dec 2024 17:41:17 -0500 Subject: [PATCH 32/44] test: arr first and wrap methods --- tests/Unit/Util/ArrTest.php | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/tests/Unit/Util/ArrTest.php b/tests/Unit/Util/ArrTest.php index 1a1c7ccf..f8950a0a 100644 --- a/tests/Unit/Util/ArrTest.php +++ b/tests/Unit/Util/ArrTest.php @@ -27,9 +27,19 @@ }); it('can check if an array has a key', function () { - $array = ['name' => 'John', 'age' => 30]; + $array = [ + 'name' => 'John', + 'age' => 30, + 'address' => [ + 'city' => 'New York', + 'zip' => '10001' + ] + ]; expect(Arr::has($array, 'name'))->toBeTrue(); expect(Arr::has($array, 'nonexistent_key'))->toBeFalse(); + expect(Arr::has($array, []))->toBeFalse(); + expect(Arr::get($array, 'address.city'))->toBeTrue(); + }); it('can undot an array', function () { @@ -52,3 +62,21 @@ expect(Arr::undot($array))->toBe($expected); }); +it('can get the first value from an array', function () { + $array = [10, 20, 30, 40]; + expect(Arr::first($array))->toBe(10); + + $array = ['name' => 'John', 'age' => 30]; + expect(Arr::first($array))->toBe('John'); + + $array = []; + expect(Arr::first($array))->toBeNull(); +}); + +it('can wrap a value in an array', function () { + expect(Arr::wrap('John'))->toBe(['John']); + expect(Arr::wrap(['John']))->toBe(['John']); + expect(Arr::wrap(null))->toBe([]); + expect(Arr::wrap(['name' => 'John']))->toBe(['name' => 'John']); +}); + From 417174ed531eacb76e5259f45db6477894f90cd5 Mon Sep 17 00:00:00 2001 From: Omar Barbosa Date: Thu, 12 Dec 2024 17:50:25 -0500 Subject: [PATCH 33/44] test: arr exists method --- src/Util/Arr.php | 4 ---- tests/Unit/Util/ArrTest.php | 43 ++++++++++++++++++++++++++++++++++++- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/src/Util/Arr.php b/src/Util/Arr.php index 61d1d2a7..76dfaf4e 100644 --- a/src/Util/Arr.php +++ b/src/Util/Arr.php @@ -162,10 +162,6 @@ public static function exists(ArrayAccess|array $array, string|int $key): bool return $array->offsetExists($key); } - if (is_int($key)) { - $key = (string) $key; - } - return array_key_exists($key, $array); } diff --git a/tests/Unit/Util/ArrTest.php b/tests/Unit/Util/ArrTest.php index f8950a0a..387204a8 100644 --- a/tests/Unit/Util/ArrTest.php +++ b/tests/Unit/Util/ArrTest.php @@ -18,6 +18,7 @@ expect(Arr::get($array, 'address.city'))->toBe('New York'); expect(Arr::get($array, 'nonexistent_key'))->toBeNull(); expect(Arr::get($array, 'nonexistent_key.dotted'))->toBeNull(); + expect(Arr::get($array, null))->toBe($array); }); it('can set a value in an array', function () { @@ -38,7 +39,7 @@ expect(Arr::has($array, 'name'))->toBeTrue(); expect(Arr::has($array, 'nonexistent_key'))->toBeFalse(); expect(Arr::has($array, []))->toBeFalse(); - expect(Arr::get($array, 'address.city'))->toBeTrue(); + expect(Arr::has($array, 'address.city'))->toBeTrue(); }); @@ -80,3 +81,43 @@ expect(Arr::wrap(['name' => 'John']))->toBe(['name' => 'John']); }); +it('can check if a key exists in an array', function () { + $array = ['name' => 'John', 'age' => 30]; + + expect(Arr::exists($array, 'name'))->toBeTrue(); + expect(Arr::exists($array, 'nonexistent_key'))->toBeFalse(); + expect(Arr::exists($array, 'age'))->toBeTrue(); + + $arrClass = new class implements ArrayAccess { + private $container = []; + + public function offsetSet($offset, $value): void + { + if ($offset === null) { + $this->container[] = $value; + } else { + $this->container[$offset] = $value; + } + } + + public function offsetExists($offset): bool + { + return isset($this->container[$offset]); + } + + public function offsetUnset($offset): void + { + unset($this->container[$offset]); + } + + public function offsetGet($offset): string|null + { + return isset($this->container[$offset]) ? $this->container[$offset] : null; + } + }; + + $arrClass['name'] = 'John'; + + expect(Arr::exists($arrClass, 'name'))->toBeTrue(); +}); + From 9e83c790fbfc163e9a622a32ecde1317f71e7b42 Mon Sep 17 00:00:00 2001 From: Omar Barbosa Date: Thu, 12 Dec 2024 18:05:28 -0500 Subject: [PATCH 34/44] test: method toArray of model collection --- src/Database/Models/Collection.php | 10 +++- src/Database/Models/DatabaseModel.php | 4 +- tests/Unit/Database/Models/CollectionTest.php | 52 +++++++++++++++++++ 3 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 tests/Unit/Database/Models/CollectionTest.php diff --git a/src/Database/Models/Collection.php b/src/Database/Models/Collection.php index 7c3dd727..775ff30d 100644 --- a/src/Database/Models/Collection.php +++ b/src/Database/Models/Collection.php @@ -6,8 +6,16 @@ use Phenix\Data\Collection as DataCollection; +/** + * @implements array + */ class Collection extends DataCollection { + public function __construct(array $data = []) + { + parent::__construct(DatabaseModel::class, $data); + } + public function modelKeys(): array { return $this->reduce(function (array $carry, DatabaseModel $model): array { @@ -19,7 +27,7 @@ public function modelKeys(): array public function map(callable $callback): self { - return new self(get_class($this->first()), array_map($callback, $this->data)); + return new self(array_map($callback, $this->data)); } public function toArray(): array diff --git a/src/Database/Models/DatabaseModel.php b/src/Database/Models/DatabaseModel.php index 6e314452..ddc20a95 100644 --- a/src/Database/Models/DatabaseModel.php +++ b/src/Database/Models/DatabaseModel.php @@ -86,7 +86,7 @@ public function getRelationshipBindings() public function newCollection(): Collection { - return new Collection($this::class); + return new Collection(); } public function getTable(): string @@ -110,7 +110,7 @@ public function toArray(): array { $data = []; - foreach ($this->propertyBindings as $property) { + foreach ($this->getPropertyBindings() as $property) { $propertyName = $property->getName(); $value = isset($this->{$propertyName}) ? $this->{$propertyName} : null; diff --git a/tests/Unit/Database/Models/CollectionTest.php b/tests/Unit/Database/Models/CollectionTest.php new file mode 100644 index 00000000..af3ac2a4 --- /dev/null +++ b/tests/Unit/Database/Models/CollectionTest.php @@ -0,0 +1,52 @@ +id = 1; + $product1->description = 'Product 1'; + $product1->price = 10.0; + $product1->stock = 100; + $product1->userId = 1; + $product1->createdAt = new Date('2023-01-01'); + $product1->updatedAt = new Date('2023-01-02'); + + $product2 = new Product(); + $product2->id = 2; + $product2->description = 'Product 2'; + $product2->price = 20.0; + $product2->stock = 200; + $product2->userId = 2; + $product2->createdAt = new Date('2023-01-03'); + $product2->updatedAt = new Date('2023-01-04'); + + $collection = new Collection([$product1, $product2]); + + $expected = [ + [ + 'id' => 1, + 'description' => 'Product 1', + 'price' => 10.0, + 'stock' => 100, + 'userId' => 1, + 'createdAt' => '2023-01-01T00:00:00+00:00', + 'updatedAt' => '2023-01-02T00:00:00+00:00', + ], + [ + 'id' => 2, + 'description' => 'Product 2', + 'price' => 20.0, + 'stock' => 200, + 'userId' => 2, + 'createdAt' => '2023-01-03T00:00:00+00:00', + 'updatedAt' => '2023-01-04T00:00:00+00:00', + ] + ]; + + expect($collection->toArray())->toBe($expected); +}); From f8a4d72c3e1aac595c07a1bed1b66da5636e50ac Mon Sep 17 00:00:00 2001 From: Omar Barbosa Date: Thu, 12 Dec 2024 18:07:00 -0500 Subject: [PATCH 35/44] test: first method of collection --- tests/Unit/Data/CollectionTest.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/Unit/Data/CollectionTest.php b/tests/Unit/Data/CollectionTest.php index a7785ecb..2ecdde2f 100644 --- a/tests/Unit/Data/CollectionTest.php +++ b/tests/Unit/Data/CollectionTest.php @@ -10,3 +10,15 @@ expect($collection)->toBeInstanceOf(Collection::class); expect($collection->isEmpty())->toBe(false); }); + +it('returns first element', function () { + $collection = Collection::fromArray([['name' => 'John'], ['name' => 'Jane']]); + + expect($collection->first())->toBe(['name' => 'John']); +}); + +it('returns null when collection is empty', function () { + $collection = new Collection('array'); + + expect($collection->first())->toBeNull(); +}); From 9afefda2c67f259d87afa7b1380ef3ce2ccf97e3 Mon Sep 17 00:00:00 2001 From: Omar Barbosa Date: Thu, 12 Dec 2024 18:10:59 -0500 Subject: [PATCH 36/44] refactor: remove docblock --- src/Database/Models/Collection.php | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/Database/Models/Collection.php b/src/Database/Models/Collection.php index 775ff30d..463c81e1 100644 --- a/src/Database/Models/Collection.php +++ b/src/Database/Models/Collection.php @@ -6,9 +6,6 @@ use Phenix\Data\Collection as DataCollection; -/** - * @implements array - */ class Collection extends DataCollection { public function __construct(array $data = []) From b0578cf756eb1325c2cdd81c867805e788e2e4d9 Mon Sep 17 00:00:00 2001 From: Omar Barbosa Date: Fri, 13 Dec 2024 12:16:33 -0500 Subject: [PATCH 37/44] test: instance type in model property --- .../QueryBuilders/DatabaseQueryBuilder.php | 2 +- .../Unit/Database/Models/Properties/Json.php | 20 ++++++++++++ .../Models/Properties/ModelPropertyTest.php | 32 +++++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/Database/Models/Properties/Json.php create mode 100644 tests/Unit/Database/Models/Properties/ModelPropertyTest.php diff --git a/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php b/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php index f06a79ea..f595367f 100644 --- a/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php +++ b/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php @@ -313,7 +313,7 @@ protected function resolveBelongsToRelationship( /** @var Collection $records */ $records = $relationship->query() - ->whereIn($relationship->getForeignKey()->getAttribute()->getColumnName(), $models->modelKeys()) + ->whereIn($relationship->getForeignKey()->getColumnName(), $models->modelKeys()) ->get(); $models->map(function (DatabaseModel $model) use ($records, $relationship): DatabaseModel { diff --git a/tests/Unit/Database/Models/Properties/Json.php b/tests/Unit/Database/Models/Properties/Json.php new file mode 100644 index 00000000..9732ecdd --- /dev/null +++ b/tests/Unit/Database/Models/Properties/Json.php @@ -0,0 +1,20 @@ +data = json_decode($data, true); + } + + public function getData(): array + { + return $this->data; + } +} diff --git a/tests/Unit/Database/Models/Properties/ModelPropertyTest.php b/tests/Unit/Database/Models/Properties/ModelPropertyTest.php new file mode 100644 index 00000000..b3f2c90a --- /dev/null +++ b/tests/Unit/Database/Models/Properties/ModelPropertyTest.php @@ -0,0 +1,32 @@ +resolveInstance())->toBeInstanceOf(Json::class); +}); + +it('gets null when value is nullable', function () { + $property = new ModelProperty( + 'data', + '?' . Json::class, + true, + new Column(name: 'data'), + null + ); + + expect($property->resolveInstance())->toBeNull(); + expect($property->getValue())->toBeNull(); +}); From 12d6427720995bb7462acf840603399968c0ca2a Mon Sep 17 00:00:00 2001 From: Omar Barbosa Date: Fri, 13 Dec 2024 16:29:48 -0500 Subject: [PATCH 38/44] test: set custom connection --- .../DatabaseQueryBuilderTest.php | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 tests/Feature/Database/Models/QueryBuilders/DatabaseQueryBuilderTest.php diff --git a/tests/Feature/Database/Models/QueryBuilders/DatabaseQueryBuilderTest.php b/tests/Feature/Database/Models/QueryBuilders/DatabaseQueryBuilderTest.php new file mode 100644 index 00000000..b3b4dd8c --- /dev/null +++ b/tests/Feature/Database/Models/QueryBuilders/DatabaseQueryBuilderTest.php @@ -0,0 +1,24 @@ + 1, 'name' => 'John Doe'], + ]; + + $this->app->swap(Connections::name('mysql'), MysqlConnectionPool::fake($data)); + + $queryBuilder = new DatabaseQueryBuilder(); + $queryBuilder->connection('mysql'); + $queryBuilder->setModel(new User()); + + $result = $queryBuilder->get()->toArray(); + + expect($result[0]['id'])->toBe($data[0]['id']); +}); From 0dee18f07218a36bc176ce39bab611a47a800f5b Mon Sep 17 00:00:00 2001 From: Omar Barbosa Date: Fri, 13 Dec 2024 17:07:32 -0500 Subject: [PATCH 39/44] refactor: share code between query builders --- src/Database/Concerns/Query/HasSentences.php | 114 +++++++++++++++ .../QueryBuilders/DatabaseQueryBuilder.php | 135 +++--------------- src/Database/QueryBuilder.php | 130 +++-------------- 3 files changed, 150 insertions(+), 229 deletions(-) create mode 100644 src/Database/Concerns/Query/HasSentences.php diff --git a/src/Database/Concerns/Query/HasSentences.php b/src/Database/Concerns/Query/HasSentences.php new file mode 100644 index 00000000..66198826 --- /dev/null +++ b/src/Database/Concerns/Query/HasSentences.php @@ -0,0 +1,114 @@ +action = Actions::SELECT; + + $query = Query::fromUri($uri); + + $currentPage = filter_var($query->get('page') ?? $defaultPage, FILTER_SANITIZE_NUMBER_INT); + $currentPage = $currentPage === false ? $defaultPage : $currentPage; + + $perPage = filter_var($query->get('per_page') ?? $defaultPerPage, FILTER_SANITIZE_NUMBER_INT); + $perPage = $perPage === false ? $defaultPerPage : $perPage; + + $total = (new self())->connection($this->connection) + ->from($this->table) + ->count(); + + $data = $this->page((int) $currentPage, (int) $perPage)->get(); + + return new Paginator($uri, $data, (int) $total, (int) $currentPage, (int) $perPage); + } + + public function count(string $column = '*'): int + { + $this->action = Actions::SELECT; + + $this->countRows($column); + + [$dml, $params] = $this->toSql(); + + /** @var array $count */ + $count = $this->connection + ->prepare($dml) + ->execute($params) + ->fetchRow(); + + return array_values($count)[0]; + } + + public function insert(array $data): bool + { + [$dml, $params] = $this->insertRows($data)->toSql(); + + try { + $this->connection->prepare($dml)->execute($params); + + return true; + } catch (SqlQueryError|SqlTransactionError) { + return false; + } + } + + public function exists(): bool + { + $this->action = Actions::EXISTS; + + $this->existsRows(); + + [$dml, $params] = $this->toSql(); + + $results = $this->connection->prepare($dml)->execute($params)->fetchRow(); + + return (bool) array_values($results)[0]; + } + + public function doesntExist(): bool + { + return ! $this->exists(); + } + + public function update(array $values): bool + { + $this->updateRow($values); + + [$dml, $params] = $this->toSql(); + + try { + $this->connection->prepare($dml)->execute($params); + + return true; + } catch (SqlQueryError|SqlTransactionError) { + return false; + } + } + + public function delete(): bool + { + $this->deleteRows(); + + [$dml, $params] = $this->toSql(); + + try { + $this->connection->prepare($dml)->execute($params); + + return true; + } catch (SqlQueryError|SqlTransactionError) { + return false; + } + } +} diff --git a/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php b/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php index f595367f..199e571e 100644 --- a/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php +++ b/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php @@ -5,14 +5,11 @@ namespace Phenix\Database\Models\QueryBuilders; use Amp\Sql\Common\SqlCommonConnectionPool; -use Amp\Sql\SqlQueryError; -use Amp\Sql\SqlTransactionError; use Closure; -use League\Uri\Components\Query; -use League\Uri\Http; use Phenix\App; use Phenix\Database\Concerns\Query\BuildsQuery; use Phenix\Database\Concerns\Query\HasJoinClause; +use Phenix\Database\Concerns\Query\HasSentences; use Phenix\Database\Constants\Actions; use Phenix\Database\Constants\Connections; use Phenix\Database\Join; @@ -24,7 +21,6 @@ use Phenix\Database\Models\Relationships\HasMany; use Phenix\Database\Models\Relationships\Relationship; use Phenix\Database\Models\Relationships\RelationshipParser; -use Phenix\Database\Paginator; use Phenix\Database\QueryBase; use Phenix\Exceptions\Database\ModelException; use Phenix\Util\Arr; @@ -35,18 +31,24 @@ class DatabaseQueryBuilder extends QueryBase { - use BuildsQuery { - table as protected; - from as protected; - insert as protected insertRows; - insertOrIgnore as protected insertOrIgnoreRows; - upsert as protected upsertRows; - insertFrom as protected insertFromRows; - update as protected updateRow; - delete as protected deleteRows; - count as protected countRows; - exists as protected existsRows; - doesntExist as protected doesntExistRows; + use BuildsQuery, HasSentences { + HasSentences::count insteadof BuildsQuery; + HasSentences::insert insteadof BuildsQuery; + HasSentences::exists insteadof BuildsQuery; + HasSentences::doesntExist insteadof BuildsQuery; + HasSentences::update insteadof BuildsQuery; + HasSentences::delete insteadof BuildsQuery; + BuildsQuery::table as protected; + BuildsQuery::from as protected; + BuildsQuery::insert as protected insertRows; + BuildsQuery::insertOrIgnore as protected insertOrIgnoreRows; + BuildsQuery::upsert as protected upsertRows; + BuildsQuery::insertFrom as protected insertFromRows; + BuildsQuery::update as protected updateRow; + BuildsQuery::delete as protected deleteRows; + BuildsQuery::count as protected countRows; + BuildsQuery::exists as protected existsRows; + BuildsQuery::doesntExist as protected doesntExistRows; } use HasJoinClause; @@ -87,105 +89,6 @@ public function addSelect(array $columns): static return $this; } - public function paginate(Http $uri, int $defaultPage = 1, int $defaultPerPage = 15): Paginator - { - $this->action = Actions::SELECT; - - $query = Query::fromUri($uri); - - $currentPage = filter_var($query->get('page') ?? $defaultPage, FILTER_SANITIZE_NUMBER_INT); - $currentPage = $currentPage === false ? $defaultPage : $currentPage; - - $perPage = filter_var($query->get('per_page') ?? $defaultPerPage, FILTER_SANITIZE_NUMBER_INT); - $perPage = $perPage === false ? $defaultPerPage : $perPage; - - $total = (new self())->connection($this->connection) - ->from($this->table) - ->count(); - - $data = $this->page((int) $currentPage, (int) $perPage)->get(); - - return new Paginator($uri, $data, (int) $total, (int) $currentPage, (int) $perPage); - } - - public function count(string $column = '*'): int - { - $this->action = Actions::SELECT; - - $this->countRows($column); - - [$dml, $params] = $this->toSql(); - - /** @var array $count */ - $count = $this->connection - ->prepare($dml) - ->execute($params) - ->fetchRow(); - - return array_values($count)[0]; - } - - public function insert(array $data): bool - { - [$dml, $params] = $this->insertRows($data)->toSql(); - - try { - $this->connection->prepare($dml)->execute($params); - - return true; - } catch (SqlQueryError|SqlTransactionError) { - return false; - } - } - - public function exists(): bool - { - $this->action = Actions::EXISTS; - - $this->existsRows(); - - [$dml, $params] = $this->toSql(); - - $results = $this->connection->prepare($dml)->execute($params)->fetchRow(); - - return (bool) array_values($results)[0]; - } - - public function doesntExist(): bool - { - return ! $this->exists(); - } - - public function update(array $values): bool - { - $this->updateRow($values); - - [$dml, $params] = $this->toSql(); - - try { - $this->connection->prepare($dml)->execute($params); - - return true; - } catch (SqlQueryError|SqlTransactionError) { - return false; - } - } - - public function delete(): bool - { - $this->deleteRows(); - - [$dml, $params] = $this->toSql(); - - try { - $this->connection->prepare($dml)->execute($params); - - return true; - } catch (SqlQueryError|SqlTransactionError) { - return false; - } - } - public function setModel(DatabaseModel $model): self { if (! isset($this->model)) { diff --git a/src/Database/QueryBuilder.php b/src/Database/QueryBuilder.php index 58d9d4b7..13a2902e 100644 --- a/src/Database/QueryBuilder.php +++ b/src/Database/QueryBuilder.php @@ -5,14 +5,11 @@ namespace Phenix\Database; use Amp\Sql\Common\SqlCommonConnectionPool; -use Amp\Sql\SqlQueryError; -use Amp\Sql\SqlTransactionError; -use League\Uri\Components\Query; -use League\Uri\Http; use Phenix\App; use Phenix\Data\Collection; use Phenix\Database\Concerns\Query\BuildsQuery; use Phenix\Database\Concerns\Query\HasJoinClause; +use Phenix\Database\Concerns\Query\HasSentences; use Phenix\Database\Constants\Actions; use Phenix\Database\Constants\Connections; @@ -20,16 +17,22 @@ class QueryBuilder extends QueryBase { - use BuildsQuery { - insert as protected insertRows; - insertOrIgnore as protected insertOrIgnoreRows; - upsert as protected upsertRows; - insertFrom as protected insertFromRows; - update as protected updateRow; - delete as protected deleteRows; - count as protected countRows; - exists as protected existsRows; - doesntExist as protected doesntExistRows; + use BuildsQuery, HasSentences { + HasSentences::count insteadof BuildsQuery; + HasSentences::insert insteadof BuildsQuery; + HasSentences::exists insteadof BuildsQuery; + HasSentences::doesntExist insteadof BuildsQuery; + HasSentences::update insteadof BuildsQuery; + HasSentences::delete insteadof BuildsQuery; + BuildsQuery::insert as protected insertRows; + BuildsQuery::insertOrIgnore as protected insertOrIgnoreRows; + BuildsQuery::upsert as protected upsertRows; + BuildsQuery::insertFrom as protected insertFromRows; + BuildsQuery::update as protected updateRow; + BuildsQuery::delete as protected deleteRows; + BuildsQuery::count as protected countRows; + BuildsQuery::exists as protected existsRows; + BuildsQuery::doesntExist as protected doesntExistRows; } use HasJoinClause; @@ -85,103 +88,4 @@ public function first(): array return $this->get()->first(); } - - public function paginate(Http $uri, int $defaultPage = 1, int $defaultPerPage = 15): Paginator - { - $this->action = Actions::SELECT; - - $query = Query::fromUri($uri); - - $currentPage = filter_var($query->get('page') ?? $defaultPage, FILTER_SANITIZE_NUMBER_INT); - $currentPage = $currentPage === false ? $defaultPage : $currentPage; - - $perPage = filter_var($query->get('per_page') ?? $defaultPerPage, FILTER_SANITIZE_NUMBER_INT); - $perPage = $perPage === false ? $defaultPerPage : $perPage; - - $total = (new self())->connection($this->connection) - ->from($this->table) - ->count(); - - $data = $this->page((int) $currentPage, (int) $perPage)->get(); - - return new Paginator($uri, $data, (int) $total, (int) $currentPage, (int) $perPage); - } - - public function count(string $column = '*'): int - { - $this->action = Actions::SELECT; - - $this->countRows($column); - - [$dml, $params] = $this->toSql(); - - /** @var array $count */ - $count = $this->connection - ->prepare($dml) - ->execute($params) - ->fetchRow(); - - return array_values($count)[0]; - } - - public function insert(array $data): bool - { - [$dml, $params] = $this->insertRows($data)->toSql(); - - try { - $this->connection->prepare($dml)->execute($params); - - return true; - } catch (SqlQueryError|SqlTransactionError) { - return false; - } - } - - public function exists(): bool - { - $this->action = Actions::EXISTS; - - $this->existsRows(); - - [$dml, $params] = $this->toSql(); - - $results = $this->connection->prepare($dml)->execute($params)->fetchRow(); - - return (bool) array_values($results)[0]; - } - - public function doesntExist(): bool - { - return ! $this->exists(); - } - - public function update(array $values): bool - { - $this->updateRow($values); - - [$dml, $params] = $this->toSql(); - - try { - $this->connection->prepare($dml)->execute($params); - - return true; - } catch (SqlQueryError|SqlTransactionError) { - return false; - } - } - - public function delete(): bool - { - $this->deleteRows(); - - [$dml, $params] = $this->toSql(); - - try { - $this->connection->prepare($dml)->execute($params); - - return true; - } catch (SqlQueryError|SqlTransactionError) { - return false; - } - } } From 7a7e71289bd00f086b58286e2cd9654bd7e7bd27 Mon Sep 17 00:00:00 2001 From: Omar Barbosa Date: Fri, 13 Dec 2024 17:07:40 -0500 Subject: [PATCH 40/44] style: phpcs --- .../DatabaseQueryBuilderTest.php | 4 ++-- tests/Unit/Database/Models/CollectionTest.php | 4 ++-- .../Models/Properties/ModelPropertyTest.php | 2 +- tests/Unit/Util/ArrTest.php | 19 +++++++++---------- 4 files changed, 14 insertions(+), 15 deletions(-) diff --git a/tests/Feature/Database/Models/QueryBuilders/DatabaseQueryBuilderTest.php b/tests/Feature/Database/Models/QueryBuilders/DatabaseQueryBuilderTest.php index b3b4dd8c..88176eff 100644 --- a/tests/Feature/Database/Models/QueryBuilders/DatabaseQueryBuilderTest.php +++ b/tests/Feature/Database/Models/QueryBuilders/DatabaseQueryBuilderTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -use Tests\Feature\Database\Models\User; use Phenix\Database\Constants\Connections; -use Tests\Mocks\Database\MysqlConnectionPool; use Phenix\Database\Models\QueryBuilders\DatabaseQueryBuilder; +use Tests\Feature\Database\Models\User; +use Tests\Mocks\Database\MysqlConnectionPool; it('sets custom connection for database query builder', function () { $data = [ diff --git a/tests/Unit/Database/Models/CollectionTest.php b/tests/Unit/Database/Models/CollectionTest.php index af3ac2a4..0cd7caec 100644 --- a/tests/Unit/Database/Models/CollectionTest.php +++ b/tests/Unit/Database/Models/CollectionTest.php @@ -3,8 +3,8 @@ declare(strict_types=1); use Phenix\Database\Models\Collection; -use Tests\Feature\Database\Models\Product; use Phenix\Util\Date; +use Tests\Feature\Database\Models\Product; it('can convert a collection of DatabaseModels to an array', function () { $product1 = new Product(); @@ -45,7 +45,7 @@ 'userId' => 2, 'createdAt' => '2023-01-03T00:00:00+00:00', 'updatedAt' => '2023-01-04T00:00:00+00:00', - ] + ], ]; expect($collection->toArray())->toBe($expected); diff --git a/tests/Unit/Database/Models/Properties/ModelPropertyTest.php b/tests/Unit/Database/Models/Properties/ModelPropertyTest.php index b3f2c90a..4cd0521c 100644 --- a/tests/Unit/Database/Models/Properties/ModelPropertyTest.php +++ b/tests/Unit/Database/Models/Properties/ModelPropertyTest.php @@ -3,8 +3,8 @@ declare(strict_types=1); use Phenix\Database\Models\Attributes\Column; -use Tests\Unit\Database\Models\Properties\Json; use Phenix\Database\Models\Properties\ModelProperty; +use Tests\Unit\Database\Models\Properties\Json; it('resolves property instance', function () { $property = new ModelProperty( diff --git a/tests/Unit/Util/ArrTest.php b/tests/Unit/Util/ArrTest.php index 387204a8..ca5b471c 100644 --- a/tests/Unit/Util/ArrTest.php +++ b/tests/Unit/Util/ArrTest.php @@ -10,8 +10,8 @@ 'age' => 30, 'address' => [ 'city' => 'New York', - 'zip' => '10001' - ] + 'zip' => '10001', + ], ]; expect(Arr::get($array, 'name'))->toBe('John'); expect(Arr::get($array, 'age'))->toBe(30); @@ -33,8 +33,8 @@ 'age' => 30, 'address' => [ 'city' => 'New York', - 'zip' => '10001' - ] + 'zip' => '10001', + ], ]; expect(Arr::has($array, 'name'))->toBeTrue(); expect(Arr::has($array, 'nonexistent_key'))->toBeFalse(); @@ -48,17 +48,17 @@ 'user.name' => 'John', 'user.age' => 30, 'address.city' => 'New York', - 'address.zip' => '10001' + 'address.zip' => '10001', ]; $expected = [ 'user' => [ 'name' => 'John', - 'age' => 30 + 'age' => 30, ], 'address' => [ 'city' => 'New York', - 'zip' => '10001' - ] + 'zip' => '10001', + ], ]; expect(Arr::undot($array))->toBe($expected); }); @@ -88,7 +88,7 @@ expect(Arr::exists($array, 'nonexistent_key'))->toBeFalse(); expect(Arr::exists($array, 'age'))->toBeTrue(); - $arrClass = new class implements ArrayAccess { + $arrClass = new class () implements ArrayAccess { private $container = []; public function offsetSet($offset, $value): void @@ -120,4 +120,3 @@ public function offsetGet($offset): string|null expect(Arr::exists($arrClass, 'name'))->toBeTrue(); }); - From cc32808db2e5b900c764d02e6f55d1a4ff9c3a1d Mon Sep 17 00:00:00 2001 From: Omar Barbosa Date: Fri, 13 Dec 2024 17:37:18 -0500 Subject: [PATCH 41/44] fix: count records with clauses --- src/Database/Concerns/Query/HasSentences.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Database/Concerns/Query/HasSentences.php b/src/Database/Concerns/Query/HasSentences.php index 66198826..d1bb6a89 100644 --- a/src/Database/Concerns/Query/HasSentences.php +++ b/src/Database/Concerns/Query/HasSentences.php @@ -25,9 +25,9 @@ public function paginate(Http $uri, int $defaultPage = 1, int $defaultPerPage = $perPage = filter_var($query->get('per_page') ?? $defaultPerPage, FILTER_SANITIZE_NUMBER_INT); $perPage = $perPage === false ? $defaultPerPage : $perPage; - $total = (new self())->connection($this->connection) - ->from($this->table) - ->count(); + $countQuery = clone $this; + + $total = $countQuery->count(); $data = $this->page((int) $currentPage, (int) $perPage)->get(); From f197e97d3c0956174fa6c7a3bedf16cb2b1dfdbf Mon Sep 17 00:00:00 2001 From: Omar Barbosa Date: Fri, 13 Dec 2024 18:18:35 -0500 Subject: [PATCH 42/44] test: check exceptions --- tests/Feature/Database/DatabaseModelTest.php | 47 +++++++++++++++++++ .../Models/Properties/ModelPropertyTest.php | 14 ++++++ 2 files changed, 61 insertions(+) diff --git a/tests/Feature/Database/DatabaseModelTest.php b/tests/Feature/Database/DatabaseModelTest.php index ac3138d1..a0e4548c 100644 --- a/tests/Feature/Database/DatabaseModelTest.php +++ b/tests/Feature/Database/DatabaseModelTest.php @@ -8,6 +8,7 @@ use Phenix\Database\Models\Relationships\BelongsTo; use Phenix\Database\Models\Relationships\BelongsToMany; use Phenix\Database\Models\Relationships\HasMany; +use Phenix\Exceptions\Database\ModelException; use Phenix\Util\Date; use Tests\Feature\Database\Models\Comment; use Tests\Feature\Database\Models\Invoice; @@ -53,6 +54,12 @@ expect($user->email)->toBe($data[0]['email']); expect($user->createdAt)->toBeInstanceOf(Date::class); expect($user->updatedAt)->toBeNull(); + + $data[0]['createdAt'] = Date::parse($data[0]['created_at'])->toIso8601String(); + unset($data[0]['created_at']); + $data[0]['updatedAt'] = null; + + expect($user->toJson())->toBe(json_encode($data[0])); }); it('loads relationship when the model belongs to a parent model', function () { @@ -645,3 +652,43 @@ expect($comment->product->user->name)->toBe($userData['name']); expect($comment->product->user->email)->toBe($userData['email']); }); + +it('dispatches error on unknown column', function () { + expect(function () { + $data = [ + [ + 'id' => 1, + 'name' => 'John Doe', + 'email' => 'john.doe@email.com', + 'created_at' => Date::now()->toDateTimeString(), + 'unknown_column' => 'unknown', + ], + ]; + + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->exactly(1)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result($data)), + ); + + $this->app->swap(Connections::default(), $connection); + + User::query()->selectAllColumns()->get(); + })->toThrow( + ModelException::class, + "Unknown column 'unknown_column' for model " . User::class, + ); +}); + +it('dispatches error on unknown relationship', function () { + expect(function () { + Post::query()->selectAllColumns() + ->with('company') + ->first(); + })->toThrow( + ModelException::class, + "Undefined relationship company for " . Post::class, + ); +}); diff --git a/tests/Unit/Database/Models/Properties/ModelPropertyTest.php b/tests/Unit/Database/Models/Properties/ModelPropertyTest.php index 4cd0521c..7c605eac 100644 --- a/tests/Unit/Database/Models/Properties/ModelPropertyTest.php +++ b/tests/Unit/Database/Models/Properties/ModelPropertyTest.php @@ -4,6 +4,7 @@ use Phenix\Database\Models\Attributes\Column; use Phenix\Database\Models\Properties\ModelProperty; +use Phenix\Util\Date; use Tests\Unit\Database\Models\Properties\Json; it('resolves property instance', function () { @@ -30,3 +31,16 @@ expect($property->resolveInstance())->toBeNull(); expect($property->getValue())->toBeNull(); }); + +it('gets null when date is nullable', function () { + $property = new ModelProperty( + 'date', + '?' . Date::class, + true, + new Column(name: 'date'), + null + ); + + expect($property->resolveInstance())->toBeNull(); + expect($property->getValue())->toBeNull(); +}); From 6a9ce5a60c52fd527e6398f247aba2110faa73df Mon Sep 17 00:00:00 2001 From: Omar Barbosa Date: Fri, 13 Dec 2024 18:26:39 -0500 Subject: [PATCH 43/44] fix: normalize type before create instance --- src/Database/Models/Properties/ModelProperty.php | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/Database/Models/Properties/ModelProperty.php b/src/Database/Models/Properties/ModelProperty.php index 9767fef6..53b8ed45 100644 --- a/src/Database/Models/Properties/ModelProperty.php +++ b/src/Database/Models/Properties/ModelProperty.php @@ -24,7 +24,7 @@ public function resolveInstance(mixed $value = null): object|null { $value ??= $this->value; - return match ($this->type) { + return match ($this->normalizedType()) { Date::class => $this->resolveDate($value), default => $this->resolveType($value), }; @@ -80,6 +80,17 @@ protected function resolveType(mixed $value): object|null return null; } - return new $this->type($value); + $type = $this->normalizedType(); + + return new $type($value); + } + + private function normalizedType(): string + { + if (str_starts_with($this->type, '?')) { + return substr($this->type, 1); + } + + return $this->type; } } From e5f7d28dfc4d8853562fa4fc326d6eabadf82439 Mon Sep 17 00:00:00 2001 From: Omar Barbosa Date: Fri, 13 Dec 2024 18:49:42 -0500 Subject: [PATCH 44/44] test: model to array --- tests/Feature/Database/DatabaseModelTest.php | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/Feature/Database/DatabaseModelTest.php b/tests/Feature/Database/DatabaseModelTest.php index a0e4548c..4259c38f 100644 --- a/tests/Feature/Database/DatabaseModelTest.php +++ b/tests/Feature/Database/DatabaseModelTest.php @@ -339,6 +339,25 @@ expect(isset($product->user))->toBeTrue(); expect($product->user->id)->toBe($userData['id']); + + $userData['createdAt'] = Date::parse($userData['created_at'])->toIso8601String(); + unset($userData['created_at']); + $userData['updatedAt'] = null; + + $productData['createdAt'] = Date::parse($productData['created_at'])->toIso8601String(); + $productData['updatedAt'] = null; + $productData['price'] = (float) $productData['price']; + $productData['userId'] = $userData['id']; + unset($productData['user_id'], $productData['created_at']); + $productData['user'] = $userData; + + $userData['products'][] = $productData; + $output = $user->toArray(); + + ksort($output['products'][0]); + ksort($userData['products'][0]); + + expect($output)->toBe($userData); }); it('loads relationship when the model has many child models loading chaperone by default', function () {