Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/Database/Concerns/Query/HasSentences.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
18 changes: 18 additions & 0 deletions src/Database/Models/Attributes/DateTime.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace Phenix\Database\Models\Attributes;

use Attribute;

#[Attribute(Attribute::TARGET_PROPERTY)]
readonly class DateTime extends Column
{
public function __construct(
public string|null $name = null,
public bool $autoInit = false,
public string $format = 'Y-m-d H:i:s',
) {
}
}
123 changes: 117 additions & 6 deletions src/Database/Models/DatabaseModel.php
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<?php

Check failure on line 1 in src/Database/Models/DatabaseModel.php

View workflow job for this annotation

GitHub Actions / test

* [Ternary to null coalescing] @@ -158,3 +158,3 @@ - $value = isset($this->{$propertyName}) ? $this->{$propertyName} : null; + $value = $this->{$propertyName} ?? null; * [Having `classes` with more than 5 cyclomatic complexity is prohibited - Consider refactoring] 22 cyclomatic complexity * [Braces] @@ -273,3 +273,3 @@ - return match($attribute::class) { + return match ($attribute::class) { BelongsToAttribute::class => new BelongsToProperty(...$arguments), * [No extra blank lines] @@ -330,3 +330,2 @@ - return $data; * [Ordered class elements] @@ -38,2 +38,4 @@ { + + public stdClass $pivot; protected string $table; @@ -42,4 +44,2 @@ - public stdClass $pivot; - /** @@ -61,4 +61,2 @@ - abstract protected static function table(): string; - public static function query(): DatabaseQueryBuilder @@ -213,2 +211,4 @@ } + + abstract protected static function table(): string;

declare(strict_types=1);

Expand All @@ -8,6 +8,7 @@
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\DateTime;
use Phenix\Database\Models\Attributes\HasMany as HasManyAttribute;
use Phenix\Database\Models\Attributes\Id;
use Phenix\Database\Models\Attributes\ModelAttribute;
Expand Down Expand Up @@ -39,7 +40,7 @@

protected ModelProperty|null $modelKey;

public stdClass $pivot;

Check failure on line 43 in src/Database/Models/DatabaseModel.php

View workflow job for this annotation

GitHub Actions / test

* [Forbidden public property] Do not use public properties. Use method access instead.

/**
* @var array<int, ModelProperty>|null
Expand Down Expand Up @@ -68,6 +69,48 @@
return $queryBuilder;
}

/**
* @param array $attributes<string, mixed>

Check failure on line 73 in src/Database/Models/DatabaseModel.php

View workflow job for this annotation

GitHub Actions / test

* [Parameter type hint] @param annotation of method \Phenix\Database\Models\DatabaseModel::create() does not specify type hint for items of its traversable parameter $attributes.
* @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

Check failure on line 98 in src/Database/Models/DatabaseModel.php

View workflow job for this annotation

GitHub Actions / test

* [Parameter type hint] Method \Phenix\Database\Models\DatabaseModel::find() has useless @param annotation for parameter $id.
* @param array $columns<int, string>

Check failure on line 99 in src/Database/Models/DatabaseModel.php

View workflow job for this annotation

GitHub Actions / test

* [Parameter type hint] @param annotation of method \Phenix\Database\Models\DatabaseModel::find() does not specify type hint for items of its traversable parameter $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<int, ModelProperty>
*/
Expand All @@ -79,7 +122,7 @@
/**
* @return array<string, array<int, Relationship>>
*/
public function getRelationshipBindings()
public function getRelationshipBindings(): array
{
return $this->relationshipBindings ??= $this->buildRelationshipBindings();
}
Expand All @@ -106,7 +149,7 @@
return $this->modelKey->getName();
}

public function toArray(): array

Check failure on line 152 in src/Database/Models/DatabaseModel.php

View workflow job for this annotation

GitHub Actions / test

* [Return type hint] Method \Phenix\Database\Models\DatabaseModel::toArray() does not have @return annotation for its traversable return value.
{
$data = [];

Expand All @@ -116,11 +159,11 @@
$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;
}
Expand All @@ -134,12 +177,47 @@
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();
}

protected function buildPropertyBindings(): array

Check failure on line 220 in src/Database/Models/DatabaseModel.php

View workflow job for this annotation

GitHub Actions / test

* [Return type hint] Method \Phenix\Database\Models\DatabaseModel::buildPropertyBindings() does not have @return annotation for its traversable return value.
{
$reflection = new ReflectionObject($this);

Expand All @@ -153,7 +231,7 @@
/** @var array<int, ModelAttribute&Column> $attributes */
$attributes = array_filter($attributes, fn (object $attr) => $attr instanceof ModelAttribute);

if (empty($attributes)) {

Check failure on line 234 in src/Database/Models/DatabaseModel.php

View workflow job for this annotation

GitHub Actions / test

* [Disallow empty] Use of empty() is disallowed.
continue;
}

Expand All @@ -166,7 +244,7 @@
return $bindings;
}

protected function buildRelationshipBindings(): array

Check failure on line 247 in src/Database/Models/DatabaseModel.php

View workflow job for this annotation

GitHub Actions / test

* [Return type hint] Method \Phenix\Database\Models\DatabaseModel::buildRelationshipBindings() does not have @return annotation for its traversable return value.
{
$relationships = [];

Expand Down Expand Up @@ -220,4 +298,37 @@
return $property->getAttribute() instanceof Id;
});
}

protected function keyIsInitialized(): bool
{
return isset($this->{$this->getModelKeyName()});
}

/**
* @return array<string, mixed>

Check failure on line 308 in src/Database/Models/DatabaseModel.php

View workflow job for this annotation

GitHub Actions / test

* [Disallow mixed type hint] Usage of "mixed" type hint is disallowed.
*/
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;
}
}
2 changes: 1 addition & 1 deletion src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ public function get(): Collection
return $collection;
}

public function first(): DatabaseModel
public function first(): DatabaseModel|null
{
$this->action = Actions::SELECT;

Expand Down
125 changes: 125 additions & 0 deletions tests/Feature/Database/DatabaseModelTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
5 changes: 3 additions & 2 deletions tests/Feature/Database/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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')]
Expand Down
5 changes: 5 additions & 0 deletions tests/Mocks/Database/Result.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,9 @@ public function getIterator(): Traversable
{
return $this->fakeResult;
}

public function getLastInsertId(): int
{
return 1;
}
}
Loading