diff --git a/src/Database/Concerns/Query/HasSentences.php b/src/Database/Concerns/Query/HasSentences.php index d1bb6a89..0f0b5f40 100644 --- a/src/Database/Concerns/Query/HasSentences.php +++ b/src/Database/Concerns/Query/HasSentences.php @@ -4,6 +4,7 @@ namespace Phenix\Database\Concerns\Query; +use Amp\Mysql\Internal\MysqlPooledResult; use Amp\Sql\SqlQueryError; use Amp\Sql\SqlTransactionError; use League\Uri\Components\Query; @@ -64,6 +65,20 @@ public function insert(array $data): bool } } + public function insertRow(array $data): int|string|bool + { + [$dml, $params] = $this->insertRows($data)->toSql(); + + try { + /** @var MysqlPooledResult $result */ + $result = $this->connection->prepare($dml)->execute($params); + + return $result->getLastInsertId(); + } catch (SqlQueryError|SqlTransactionError) { + return false; + } + } + public function exists(): bool { $this->action = Actions::EXISTS; diff --git a/src/Database/Models/Attributes/DateTime.php b/src/Database/Models/Attributes/DateTime.php new file mode 100644 index 00000000..5e881ca0 --- /dev/null +++ b/src/Database/Models/Attributes/DateTime.php @@ -0,0 +1,18 @@ + + * @throws ModelException + * @return static + */ + public static function create(array $attributes): static + { + $model = new static(); + $propertyBindings = $model->getPropertyBindings(); + + foreach ($attributes as $key => $value) { + $property = $propertyBindings[$key] ?? null; + + if (! $property) { + throw new ModelException("Property {$key} not found for model " . static::class); + } + + $model->{$property->getName()} = $value; + } + + $model->save(); + + return $model; + } + + /** + * @param string|int $id + * @param array $columns + * @return DatabaseModel|null + */ + public static function find(string|int $id, array $columns = ['*']): self|null + { + $model = new static(); + $queryBuilder = static::newQueryBuilder(); + $queryBuilder->setModel($model); + + return $queryBuilder + ->select($columns) + ->whereEqual($model->getModelKeyName(), $id) + ->first(); + } + /** * @return array */ @@ -79,7 +122,7 @@ public function getPropertyBindings(): array /** * @return array> */ - public function getRelationshipBindings() + public function getRelationshipBindings(): array { return $this->relationshipBindings ??= $this->buildRelationshipBindings(); } @@ -116,11 +159,11 @@ public function toArray(): array $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(); - } + $value = match (true) { + $value instanceof Arrayable => $value->toArray(), + $value instanceof Date => $value->toIso8601String(), + default => $value, + }; $data[$propertyName] = $value; } @@ -134,6 +177,41 @@ public function toJson(): string return json_encode($this->toArray()); } + public function save(): bool + { + $data = $this->buildSavingData(); + + $queryBuilder = static::newQueryBuilder(); + $queryBuilder->setModel($this); + + if ($this->keyIsInitialized()) { + unset($data[$this->getModelKeyName()]); + + return $queryBuilder->whereEqual($this->getModelKeyName(), $this->getKey()) + ->update($data); + } + + $result = $queryBuilder->insertRow($data); + + if ($result) { + $this->{$this->getModelKeyName()} = $result; + + return true; + } + + return false; + } + + public function delete(): bool + { + $queryBuilder = static::newQueryBuilder(); + $queryBuilder->setModel($this); + + return $queryBuilder + ->whereEqual($this->getModelKeyName(), $this->getKey()) + ->delete(); + } + protected static function newQueryBuilder(): DatabaseQueryBuilder { return new DatabaseQueryBuilder(); @@ -220,4 +298,37 @@ protected function findModelKey(): ModelProperty return $property->getAttribute() instanceof Id; }); } + + protected function keyIsInitialized(): bool + { + return isset($this->{$this->getModelKeyName()}); + } + + /** + * @return array + */ + protected function buildSavingData(): array + { + $data = []; + + foreach ($this->getPropertyBindings() as $property) { + $propertyName = $property->getName(); + $attribute = $property->getAttribute(); + + if (isset($this->{$propertyName})) { + $data[$property->getColumnName()] = $this->{$propertyName}; + } + + if ($attribute instanceof DateTime && $attribute->autoInit && ! isset($this->{$propertyName})) { + $now = Date::now(); + + $data[$property->getColumnName()] = $now->format($attribute->format); + + $this->{$propertyName} = $now; + } + } + + + return $data; + } } diff --git a/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php b/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php index 199e571e..6b3db4dd 100644 --- a/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php +++ b/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php @@ -152,7 +152,7 @@ public function get(): Collection return $collection; } - public function first(): DatabaseModel + public function first(): DatabaseModel|null { $this->action = Actions::SELECT; diff --git a/tests/Feature/Database/DatabaseModelTest.php b/tests/Feature/Database/DatabaseModelTest.php index 4259c38f..5e28a5e7 100644 --- a/tests/Feature/Database/DatabaseModelTest.php +++ b/tests/Feature/Database/DatabaseModelTest.php @@ -711,3 +711,128 @@ "Undefined relationship company for " . Post::class, ); }); + +it('saves a new model', function () { + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->exactly(1)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result([['Query OK']])), + ); + + $this->app->swap(Connections::default(), $connection); + + $model = new User(); + $model->name = 'John Doe'; + $model->email = faker()->email(); + + expect($model->save())->toBeTrue(); + expect($model->id)->toBe(1); + expect($model->createdAt)->toBeInstanceOf(Date::class); +}); + +it('updates a model successfully', function () { + $model = new User(); + $model->id = 1; + $model->name = 'John Doe'; + $model->email = faker()->email(); + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->exactly(1)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result([['Query OK']])), + ); + + $this->app->swap(Connections::default(), $connection); + + $model->name = 'John Doe Jr.'; + + expect($model->save())->toBeTrue(); +}); + +it('creates model instance successfully', function () { + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->exactly(1)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result([['Query OK']])), + ); + + $this->app->swap(Connections::default(), $connection); + + $model = User::create([ + 'name' => 'John Doe', + 'email' => faker()->email(), + 'created_at' => Date::now(), + ]); + + expect($model->id)->toBe(1); + expect($model->createdAt)->toBeInstanceOf(Date::class); +}); + +it('throws an exception when column in invalid on create instance', function () { + expect(function () { + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $this->app->swap(Connections::default(), $connection); + + User::create([ + 'name' => 'John Doe', + 'email' => faker()->email(), + 'other_date' => Date::now(), + ]); + })->toThrow( + ModelException::class, + "Property other_date not found for model " . User::class, + ); +}); + +it('finds a model 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); + + /** @var User $user */ + $user = User::find(1); + + expect($user)->toBeInstanceOf(User::class); + expect($user->id)->toBe($data['id']); + expect($user->name)->toBe($data['name']); + expect($user->email)->toBe($data['email']); + expect($user->createdAt)->toBeInstanceOf(Date::class); +}); + +it('deletes a model successfully', function () { + $model = new User(); + $model->id = 1; + $model->name = 'John Doe'; + $model->email = faker()->email(); + + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->exactly(1)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result([['Query OK']])), + ); + + $this->app->swap(Connections::default(), $connection); + + expect($model->delete())->toBeTrue(); +}); diff --git a/tests/Feature/Database/Models/User.php b/tests/Feature/Database/Models/User.php index ab85847d..ebd36f34 100644 --- a/tests/Feature/Database/Models/User.php +++ b/tests/Feature/Database/Models/User.php @@ -5,6 +5,7 @@ namespace Tests\Feature\Database\Models; use Phenix\Database\Models\Attributes\Column; +use Phenix\Database\Models\Attributes\DateTime; use Phenix\Database\Models\Attributes\HasMany; use Phenix\Database\Models\Attributes\Id; use Phenix\Database\Models\Collection; @@ -22,10 +23,10 @@ class User extends DatabaseModel #[Column] public string $email; - #[Column(name: 'created_at')] + #[DateTime(name: 'created_at', autoInit: true)] public Date $createdAt; - #[Column(name: 'updated_at')] + #[DateTime(name: 'updated_at')] public Date|null $updatedAt = null; #[HasMany(model: Product::class, foreignKey: 'user_id')] diff --git a/tests/Mocks/Database/Result.php b/tests/Mocks/Database/Result.php index 733e9d2c..707f99c3 100644 --- a/tests/Mocks/Database/Result.php +++ b/tests/Mocks/Database/Result.php @@ -44,4 +44,9 @@ public function getIterator(): Traversable { return $this->fakeResult; } + + public function getLastInsertId(): int + { + return 1; + } }