diff --git a/docs/1-essentials/01-routing.md b/docs/1-essentials/01-routing.md index 44b1b852d..c487b8c93 100644 --- a/docs/1-essentials/01-routing.md +++ b/docs/1-essentials/01-routing.md @@ -103,6 +103,24 @@ final class Aircraft implements Bindable } ``` +By default, `Bindable` objects will be cast to strings when they are passed into the `uri()` function as a route parameter. You can override this default behaviour by tagging a public property on the object with the {b`\Tempest\Router\IsBindingValue`} attribute: + +```php +use Tempest\Router\Bindable; +use Tempest\Router\IsBindingValue; + +final class Aircraft implements Bindable +{ + #[IsBindingValue] + public string $callSign; + + public function resolve(string $input): self + { + return self::find(id: $input); + } +} +``` + ### Backed enum binding You may inject string-backed enumerations to controller actions. Tempest will try to map the corresponding parameter from the URI to an instance of that enum using the [`tryFrom`](https://www.php.net/manual/en/backedenum.tryfrom.php) enum method. diff --git a/packages/database/src/Builder/ModelInspector.php b/packages/database/src/Builder/ModelInspector.php index eb5c44681..cffaf01d5 100644 --- a/packages/database/src/Builder/ModelInspector.php +++ b/packages/database/src/Builder/ModelInspector.php @@ -6,8 +6,6 @@ use Tempest\Database\BelongsTo; use Tempest\Database\Config\DatabaseConfig; use Tempest\Database\Eager; -use Tempest\Database\Exceptions\ModelDidNotHavePrimaryColumn; -use Tempest\Database\Exceptions\ModelHadMultiplePrimaryColumns; use Tempest\Database\HasMany; use Tempest\Database\HasOne; use Tempest\Database\PrimaryKey; @@ -108,6 +106,14 @@ public function getPropertyValues(): array $values = []; foreach ($this->reflector->getProperties() as $property) { + if ($property->isVirtual()) { + continue; + } + + if ($property->hasAttribute(Virtual::class)) { + continue; + } + if (! $property->isInitialized($this->instance)) { continue; } @@ -247,6 +253,81 @@ public function getRelation(string|PropertyReflector $name): ?Relation return $this->getBelongsTo($name) ?? $this->getHasOne($name) ?? $this->getHasMany($name); } + /** + * @return \Tempest\Support\Arr\ImmutableArray + */ + public function getRelations(): ImmutableArray + { + if (! $this->isObjectModel()) { + return arr(); + } + + $relationFields = arr(); + + foreach ($this->reflector->getPublicProperties() as $property) { + if ($relation = $this->getRelation($property->getName())) { + $relationFields[] = $relation; + } + } + + return $relationFields; + } + + /** + * @return \Tempest\Support\Arr\ImmutableArray + */ + public function getValueFields(): ImmutableArray + { + if (! $this->isObjectModel()) { + return arr(); + } + + $valueFields = arr(); + + foreach ($this->reflector->getPublicProperties() as $property) { + if ($property->isVirtual()) { + continue; + } + + if ($property->hasAttribute(Virtual::class)) { + continue; + } + + if ($this->isRelation($property->getName())) { + continue; + } + + $valueFields[] = $property; + } + + return $valueFields; + } + + public function isRelationLoaded(string|PropertyReflector|Relation $relation): bool + { + if (! $this->isObjectModel()) { + return false; + } + + if (! ($relation instanceof Relation)) { + $relation = $this->getRelation($relation); + } + + if (! $relation) { + return false; + } + + if (! $relation->property->isInitialized($this->instance)) { + return false; + } + + if ($relation->property->getValue($this->instance) === null) { + return false; + } + + return true; + } + public function getSelectFields(): ImmutableArray { if (! $this->isObjectModel()) { @@ -255,6 +336,10 @@ public function getSelectFields(): ImmutableArray $selectFields = arr(); + if ($primaryKey = $this->getPrimaryKeyProperty()) { + $selectFields[] = $primaryKey->getName(); + } + foreach ($this->reflector->getPublicProperties() as $property) { $relation = $this->getRelation($property->getName()); @@ -266,6 +351,10 @@ public function getSelectFields(): ImmutableArray continue; } + if ($property->getType()->equals(PrimaryKey::class)) { + continue; + } + if ($relation instanceof BelongsTo) { $selectFields[] = $relation->getOwnerFieldName(); } else { @@ -417,11 +506,7 @@ public function getPrimaryKeyProperty(): ?PropertyReflector return match ($primaryKeys->count()) { 0 => null, - 1 => $primaryKeys->first(), - default => throw ModelHadMultiplePrimaryColumns::found( - model: $this->model, - properties: $primaryKeys->map(fn (PropertyReflector $property) => $property->getName())->toArray(), - ), + default => $primaryKeys->first(), }; } diff --git a/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php index fcf835a9b..ba9f984d8 100644 --- a/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php @@ -149,6 +149,14 @@ private function convertObjectToArray(object $object, array $excludeProperties = continue; } + if ($property->isVirtual()) { + continue; + } + + if ($property->isUninitialized($object)) { + continue; + } + $propertyName = $property->getName(); if (! in_array($propertyName, $excludeProperties, strict: true)) { diff --git a/packages/database/src/Exceptions/ModelHadMultiplePrimaryColumns.php b/packages/database/src/Exceptions/ModelHadMultiplePrimaryColumns.php deleted file mode 100644 index 15e045f96..000000000 --- a/packages/database/src/Exceptions/ModelHadMultiplePrimaryColumns.php +++ /dev/null @@ -1,23 +0,0 @@ -join(); - - return new self("`{$model}` has multiple `Id` properties ({$propertyNames}). Only one `Id` property is allowed per model."); - } -} diff --git a/packages/database/src/IsDatabaseModel.php b/packages/database/src/IsDatabaseModel.php index 3467f8375..2a9d6811d 100644 --- a/packages/database/src/IsDatabaseModel.php +++ b/packages/database/src/IsDatabaseModel.php @@ -9,14 +9,19 @@ use Tempest\Database\Builder\QueryBuilders\SelectQueryBuilder; use Tempest\Database\Exceptions\RelationWasMissing; use Tempest\Database\Exceptions\ValueWasMissing; -use Tempest\Database\Virtual; use Tempest\Reflection\ClassReflector; use Tempest\Reflection\PropertyReflector; +use Tempest\Router\IsBindingValue; +use Tempest\Validation\SkipValidation; -use function Tempest\Database\query; +use function Tempest\Support\arr; +use function Tempest\Support\str; trait IsDatabaseModel { + #[IsBindingValue, SkipValidation] + public PrimaryKey $id; + /** * Returns a builder for selecting records using this model's table. * @@ -58,7 +63,7 @@ public static function new(mixed ...$params): self /** * Finds a model instance by its ID. */ - public static function findById(string|int|PrimaryKey $id): static + public static function findById(string|int|PrimaryKey $id): self { return self::get($id); } @@ -66,7 +71,7 @@ public static function findById(string|int|PrimaryKey $id): static /** * Finds a model instance by its ID. */ - public static function resolve(string|int|PrimaryKey $id): static + public static function resolve(string|int|PrimaryKey $id): self { return query(self::class)->resolve($id); } @@ -152,7 +157,6 @@ public static function findOrNew(array $find, array $update): self * * @param array $find Properties to search for in the existing model. * @param array $update Properties to update or set on the model if it is found or created. - * @return TModel */ public static function updateOrCreate(array $find, array $update): self { @@ -166,23 +170,30 @@ public function refresh(): self { $model = inspect($this); - if (! $model->hasPrimaryKey()) { - throw Exceptions\ModelDidNotHavePrimaryColumn::neededForMethod($this, 'refresh'); - } + $loadedRelations = $model + ->getRelations() + ->filter(fn (Relation $relation) => $model->isRelationLoaded($relation)); - $relations = []; + $primaryKeyProperty = $model->getPrimaryKeyProperty(); + $primaryKeyValue = $primaryKeyProperty->getValue($this); - foreach (new ClassReflector($this)->getPublicProperties() as $property) { - if (! $property->getValue($this)) { - continue; - } + $new = self::select() + ->with(...$loadedRelations->map(fn (Relation $relation) => $relation->name)) + ->get($primaryKeyValue); - if ($model->isRelation($property->getName())) { - $relations[] = $property->getName(); - } + foreach ($loadedRelations as $relation) { + $relation->property->setValue( + object: $this, + value: $relation->property->getValue($new), + ); } - $this->load(...$relations); + foreach ($model->getValueFields() as $property) { + $property->setValue( + object: $this, + value: $property->getValue($new), + ); + } return $this; } @@ -194,21 +205,17 @@ public function load(string ...$relations): self { $model = inspect($this); - if (! $model->hasPrimaryKey()) { - throw Exceptions\ModelDidNotHavePrimaryColumn::neededForMethod($this, 'load'); - } - $primaryKeyProperty = $model->getPrimaryKeyProperty(); $primaryKeyValue = $primaryKeyProperty->getValue($this); $new = self::get($primaryKeyValue, $relations); - foreach (new ClassReflector($new)->getPublicProperties() as $property) { - if ($property->hasAttribute(Virtual::class)) { - continue; - } + $fieldsToUpdate = arr($relations) + ->map(fn (string $relation) => str($relation)->before('.')->toString()) + ->unique(); - $property->setValue($this, $property->getValue($new)); + foreach ($fieldsToUpdate as $fieldToUpdate) { + $this->{$fieldToUpdate} = $new->{$fieldToUpdate}; } return $this; @@ -262,10 +269,6 @@ public function update(mixed ...$params): self { $model = inspect($this); - if (! $model->hasPrimaryKey()) { - throw Exceptions\ModelDidNotHavePrimaryColumn::neededForMethod($this, 'update'); - } - $model->validate(...$params); query($this) diff --git a/packages/router/src/GenericRouter.php b/packages/router/src/GenericRouter.php index a21974c7f..dd1e6ac87 100644 --- a/packages/router/src/GenericRouter.php +++ b/packages/router/src/GenericRouter.php @@ -6,8 +6,10 @@ use BackedEnum; use Psr\Http\Message\ServerRequestInterface as PsrRequest; +use ReflectionClass; use Tempest\Container\Container; use Tempest\Core\AppConfig; +use Tempest\Database\PrimaryKey; use Tempest\Http\Mappers\PsrRequestToGenericRequestMapper; use Tempest\Http\Request; use Tempest\Http\Response; @@ -126,6 +128,15 @@ public function toUri(array|string $action, ...$params): string if ($value instanceof BackedEnum) { $value = $value->value; + } elseif ($value instanceof Bindable) { + foreach (new ClassReflector($value)->getPublicProperties() as $property) { + if (! $property->hasAttribute(IsBindingValue::class)) { + continue; + } + + $value = $property->getValue($value); + break; + } } $uri = $uri->replaceRegex( diff --git a/packages/router/src/IsBindingValue.php b/packages/router/src/IsBindingValue.php new file mode 100644 index 000000000..d2f633936 --- /dev/null +++ b/packages/router/src/IsBindingValue.php @@ -0,0 +1,10 @@ +uri), $route->middleware, $methodReflector, - $route->without, + $route->without ?? [], ); } diff --git a/tests/Fixtures/Models/A.php b/tests/Fixtures/Models/A.php index 82cdd5659..d6231ec95 100644 --- a/tests/Fixtures/Models/A.php +++ b/tests/Fixtures/Models/A.php @@ -13,8 +13,6 @@ final class A { use IsDatabaseModel; - public PrimaryKey $id; - public function __construct( public B $b, ) {} diff --git a/tests/Fixtures/Models/AWithEager.php b/tests/Fixtures/Models/AWithEager.php index d90545a5e..a55da7058 100644 --- a/tests/Fixtures/Models/AWithEager.php +++ b/tests/Fixtures/Models/AWithEager.php @@ -14,8 +14,6 @@ final class AWithEager { use IsDatabaseModel; - public PrimaryKey $id; - public function __construct( #[Eager] public BWithEager $b, diff --git a/tests/Fixtures/Models/AWithLazy.php b/tests/Fixtures/Models/AWithLazy.php index 72a5b907a..ac54c56e1 100644 --- a/tests/Fixtures/Models/AWithLazy.php +++ b/tests/Fixtures/Models/AWithLazy.php @@ -14,8 +14,6 @@ final class AWithLazy { use IsDatabaseModel; - public PrimaryKey $id; - public function __construct( #[Lazy] public B $b, diff --git a/tests/Fixtures/Models/AWithValue.php b/tests/Fixtures/Models/AWithValue.php index 86ddd70dc..386afe5ed 100644 --- a/tests/Fixtures/Models/AWithValue.php +++ b/tests/Fixtures/Models/AWithValue.php @@ -14,8 +14,6 @@ final class AWithValue { use IsDatabaseModel; - public PrimaryKey $id; - public function __construct( public string $name, ) {} diff --git a/tests/Fixtures/Models/AWithVirtual.php b/tests/Fixtures/Models/AWithVirtual.php index 8e29a56eb..f4867f63e 100644 --- a/tests/Fixtures/Models/AWithVirtual.php +++ b/tests/Fixtures/Models/AWithVirtual.php @@ -15,8 +15,6 @@ final class AWithVirtual { use IsDatabaseModel; - public PrimaryKey $id; - #[Virtual] public int $fake { get => -$this->id->value; diff --git a/tests/Fixtures/Models/B.php b/tests/Fixtures/Models/B.php index ca79db9a8..e82d94653 100644 --- a/tests/Fixtures/Models/B.php +++ b/tests/Fixtures/Models/B.php @@ -13,8 +13,6 @@ final class B { use IsDatabaseModel; - public PrimaryKey $id; - public function __construct( public C $c, ) {} diff --git a/tests/Fixtures/Models/BWithEager.php b/tests/Fixtures/Models/BWithEager.php index d5517207f..5a28a9ffd 100644 --- a/tests/Fixtures/Models/BWithEager.php +++ b/tests/Fixtures/Models/BWithEager.php @@ -14,8 +14,6 @@ final class BWithEager { use IsDatabaseModel; - public PrimaryKey $id; - public function __construct( #[Eager] public C $c, diff --git a/tests/Fixtures/Models/C.php b/tests/Fixtures/Models/C.php index 87bbe9cfb..6b26c2995 100644 --- a/tests/Fixtures/Models/C.php +++ b/tests/Fixtures/Models/C.php @@ -13,8 +13,6 @@ final class C { use IsDatabaseModel; - public PrimaryKey $id; - public function __construct( public string $name, ) {} diff --git a/tests/Fixtures/Models/MultiWordModel.php b/tests/Fixtures/Models/MultiWordModel.php index cd42f5116..65ca144ad 100644 --- a/tests/Fixtures/Models/MultiWordModel.php +++ b/tests/Fixtures/Models/MultiWordModel.php @@ -10,6 +10,4 @@ final class MultiWordModel { use IsDatabaseModel; - - public PrimaryKey $id; } diff --git a/tests/Fixtures/Modules/Books/Models/Author.php b/tests/Fixtures/Modules/Books/Models/Author.php index 011b62e00..6d81b271e 100644 --- a/tests/Fixtures/Modules/Books/Models/Author.php +++ b/tests/Fixtures/Modules/Books/Models/Author.php @@ -6,6 +6,7 @@ use Tempest\Database\IsDatabaseModel; use Tempest\Database\PrimaryKey; +use Tempest\Database\Virtual; use Tempest\Router\Bindable; use Tempest\Validation\SkipValidation; @@ -13,9 +14,6 @@ final class Author implements Bindable { use IsDatabaseModel; - #[SkipValidation] - public PrimaryKey $id; - public function __construct( public string $name, public ?AuthorType $type = AuthorType::A, diff --git a/tests/Fixtures/Modules/Books/Models/Book.php b/tests/Fixtures/Modules/Books/Models/Book.php index 434fb1594..09e22f8cf 100644 --- a/tests/Fixtures/Modules/Books/Models/Book.php +++ b/tests/Fixtures/Modules/Books/Models/Book.php @@ -7,6 +7,7 @@ use Tempest\Database\HasOne; use Tempest\Database\IsDatabaseModel; use Tempest\Database\PrimaryKey; +use Tempest\Database\Virtual; use Tempest\Router\Bindable; use Tempest\Validation\Rules\HasLength; use Tempest\Validation\SkipValidation; @@ -15,9 +16,6 @@ final class Book implements Bindable { use IsDatabaseModel; - #[SkipValidation] - public PrimaryKey $id; - #[HasLength(min: 1, max: 120)] public string $title; diff --git a/tests/Fixtures/Modules/Books/Models/Chapter.php b/tests/Fixtures/Modules/Books/Models/Chapter.php index 74fda7ae2..ffd0b4f08 100644 --- a/tests/Fixtures/Modules/Books/Models/Chapter.php +++ b/tests/Fixtures/Modules/Books/Models/Chapter.php @@ -11,8 +11,6 @@ final class Chapter { use IsDatabaseModel; - public PrimaryKey $id; - public string $title; public ?string $contents; diff --git a/tests/Fixtures/Modules/Books/Models/Isbn.php b/tests/Fixtures/Modules/Books/Models/Isbn.php index c3adbc075..6cccc48ba 100644 --- a/tests/Fixtures/Modules/Books/Models/Isbn.php +++ b/tests/Fixtures/Modules/Books/Models/Isbn.php @@ -9,8 +9,6 @@ final class Isbn { use IsDatabaseModel; - public PrimaryKey $id; - public string $value; public Book $book; diff --git a/tests/Integration/Database/Builder/CustomPrimaryKeyTest.php b/tests/Integration/Database/Builder/CustomPrimaryKeyTest.php index 4c8e0e251..bceadc083 100644 --- a/tests/Integration/Database/Builder/CustomPrimaryKeyTest.php +++ b/tests/Integration/Database/Builder/CustomPrimaryKeyTest.php @@ -3,14 +3,12 @@ namespace Tests\Tempest\Integration\Database\Builder; use Tempest\Database\DatabaseMigration; -use Tempest\Database\Exceptions\ModelHadMultiplePrimaryColumns; use Tempest\Database\Migrations\CreateMigrationsTable; use Tempest\Database\PrimaryKey; use Tempest\Database\QueryStatement; use Tempest\Database\QueryStatements\CreateTableStatement; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; -use function Tempest\Database\inspect; use function Tempest\Database\query; final class CustomPrimaryKeyTest extends FrameworkIntegrationTestCase @@ -47,16 +45,6 @@ public function test_update_or_create_with_custom_primary_key(): void $this->assertSame('Advanced Time Magic', $updated->magic); } - public function test_model_with_multiple_id_properties_throws_exception(): void - { - $this->expectException(ModelHadMultiplePrimaryColumns::class); - $this->expectExceptionMessage( - '`Tests\Tempest\Integration\Database\Builder\ModelWithMultipleIds` has multiple `Id` properties (uuid and external_id). Only one `Id` property is allowed per model.', - ); - - inspect(ModelWithMultipleIds::class)->getPrimaryKey(); - } - public function test_model_without_id_property_still_works(): void { $this->migrate(CreateMigrationsTable::class, CreateModelWithoutIdMigration::class); diff --git a/tests/Integration/Database/Builder/InsertRelationsTest.php b/tests/Integration/Database/Builder/InsertRelationsTest.php index 838bee339..47ee54fa1 100644 --- a/tests/Integration/Database/Builder/InsertRelationsTest.php +++ b/tests/Integration/Database/Builder/InsertRelationsTest.php @@ -22,6 +22,7 @@ use Tests\Tempest\Fixtures\Modules\Books\Models\Isbn; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; +use function Tempest\Database\inspect; use function Tempest\Database\query; final class InsertRelationsTest extends FrameworkIntegrationTestCase diff --git a/tests/Integration/Database/Builder/IsDatabaseModelTest.php b/tests/Integration/Database/Builder/IsDatabaseModelTest.php index 755d642f2..7d11eee99 100644 --- a/tests/Integration/Database/Builder/IsDatabaseModelTest.php +++ b/tests/Integration/Database/Builder/IsDatabaseModelTest.php @@ -585,47 +585,6 @@ public function test_delete_via_model_instance_with_primary_key(): void $this->assertSame('second', Foo::get($foo2->id)->bar); } - public function test_delete_via_model_instance_without_primary_key(): void - { - $this->migrate( - CreateMigrationsTable::class, - CreateModelWithoutPrimaryKeyMigration::class, - ); - - $model = new ModelWithoutPrimaryKey(name: 'Frieren', description: 'Elf mage'); - $model->save(); - - $this->expectException(DeleteStatementWasInvalid::class); - $model->delete(); - } - - public function test_delete_via_model_class_without_primary_key(): void - { - $this->migrate( - CreateMigrationsTable::class, - CreateModelWithoutPrimaryKeyMigration::class, - ); - - query(ModelWithoutPrimaryKey::class)->create(name: 'Himmel', description: 'Hero'); - query(ModelWithoutPrimaryKey::class)->create(name: 'Heiter', description: 'Priest'); - query(ModelWithoutPrimaryKey::class)->create(name: 'Eisen', description: 'Warrior'); - - $this->assertCount(3, query(ModelWithoutPrimaryKey::class)->select()->all()); - - query(ModelWithoutPrimaryKey::class) - ->delete() - ->where('name', 'Himmel') - ->execute(); - - $remaining = query(ModelWithoutPrimaryKey::class)->select()->all(); - $this->assertCount(2, $remaining); - - $names = array_map(fn (ModelWithoutPrimaryKey $model) => $model->name, $remaining); - $this->assertContains('Heiter', $names); - $this->assertContains('Eisen', $names); - $this->assertNotContains('Himmel', $names); - } - public function test_delete_with_uninitialized_primary_key(): void { $this->migrate( @@ -664,8 +623,6 @@ final class Foo { use IsDatabaseModel; - public PrimaryKey $id; - public string $bar; } @@ -865,15 +822,11 @@ public function down(): ?QueryStatement final class AttributeTableNameModel { use IsDatabaseModel; - - public PrimaryKey $id; } final class BaseModel { use IsDatabaseModel; - - public PrimaryKey $id; } final readonly class CarbonCaster implements Caster @@ -888,8 +841,6 @@ final class CarbonModel { use IsDatabaseModel; - public PrimaryKey $id; - public function __construct( public Carbon $createdAt, ) {} @@ -917,8 +868,6 @@ final class CasterModel { use IsDatabaseModel; - public PrimaryKey $id; - public function __construct( public DateTimeImmutable $date, public array $array_prop, @@ -931,8 +880,6 @@ final class ChildModel { use IsDatabaseModel; - public PrimaryKey $id; - #[HasOne] public ThroughModel $through; @@ -959,8 +906,6 @@ final class ModelWithValidation { use IsDatabaseModel; - public PrimaryKey $id; - #[IsBetween(min: 1, max: 10)] public int $index; @@ -973,8 +918,6 @@ final class ParentModel { use IsDatabaseModel; - public PrimaryKey $id; - public function __construct( public string $name, @@ -987,8 +930,6 @@ public function __construct( final class StaticMethodTableNameModel { use IsDatabaseModel; - - public PrimaryKey $id; } #[Table('through')] @@ -996,8 +937,6 @@ final class ThroughModel { use IsDatabaseModel; - public PrimaryKey $id; - public function __construct( public ParentModel $parent, public ChildModel $child, @@ -1010,8 +949,6 @@ final class TestUser { use IsDatabaseModel; - public PrimaryKey $id; - /** @var \Tests\Tempest\Integration\Database\Builder\TestPost[] */ #[HasMany] public array $posts = []; @@ -1025,8 +962,6 @@ final class TestPost { use IsDatabaseModel; - public PrimaryKey $id; - public function __construct( public string $title, public string $body, @@ -1071,8 +1006,6 @@ public function down(): ?QueryStatement final class ModelWithoutPrimaryKey { - use IsDatabaseModel; - public function __construct( public string $name, public string $description, diff --git a/tests/Integration/Database/Builder/UpdateQueryBuilderDtoTest.php b/tests/Integration/Database/Builder/UpdateQueryBuilderDtoTest.php index c4d8c3973..6a5c26ab5 100644 --- a/tests/Integration/Database/Builder/UpdateQueryBuilderDtoTest.php +++ b/tests/Integration/Database/Builder/UpdateQueryBuilderDtoTest.php @@ -68,8 +68,6 @@ final class UserWithDtoSettings { use IsDatabaseModel; - public PrimaryKey $id; - public function __construct( public string $name, public DtoSettings $settings, diff --git a/tests/Integration/Database/Builder/UpdateQueryBuilderTest.php b/tests/Integration/Database/Builder/UpdateQueryBuilderTest.php index 0a83551d4..18cb2e47d 100644 --- a/tests/Integration/Database/Builder/UpdateQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/UpdateQueryBuilderTest.php @@ -469,8 +469,6 @@ final class TestModelWithRelations { use IsDatabaseModel; - public PrimaryKey $id; - #[HasMany] public array $items = []; diff --git a/tests/Integration/Database/ConvenientDateWhereMethodsTest.php b/tests/Integration/Database/ConvenientDateWhereMethodsTest.php index 482c7097d..e126039e5 100644 --- a/tests/Integration/Database/ConvenientDateWhereMethodsTest.php +++ b/tests/Integration/Database/ConvenientDateWhereMethodsTest.php @@ -387,8 +387,6 @@ final class Event { use IsDatabaseModel; - public PrimaryKey $id; - public function __construct( public string $name, public DateTime $created_at, diff --git a/tests/Integration/Database/ConvenientWhereMethodsTest.php b/tests/Integration/Database/ConvenientWhereMethodsTest.php index 39aaf9878..38507fa80 100644 --- a/tests/Integration/Database/ConvenientWhereMethodsTest.php +++ b/tests/Integration/Database/ConvenientWhereMethodsTest.php @@ -422,8 +422,6 @@ final class User { use IsDatabaseModel; - public PrimaryKey $id; - public function __construct( public string $name, public ?string $email, diff --git a/tests/Integration/Database/GroupedWhereMethodsTest.php b/tests/Integration/Database/GroupedWhereMethodsTest.php index 66ba60e80..5ed9327e1 100644 --- a/tests/Integration/Database/GroupedWhereMethodsTest.php +++ b/tests/Integration/Database/GroupedWhereMethodsTest.php @@ -352,8 +352,6 @@ final class Product { use IsDatabaseModel; - public PrimaryKey $id; - public function __construct( public string $name, public string $category, diff --git a/tests/Integration/Database/ModelInspector/ModelInspectorTest.php b/tests/Integration/Database/ModelInspector/ModelInspectorTest.php index c9582b52f..86734c056 100644 --- a/tests/Integration/Database/ModelInspector/ModelInspectorTest.php +++ b/tests/Integration/Database/ModelInspector/ModelInspectorTest.php @@ -47,8 +47,6 @@ final class ModelInspectorTestModelWithVirtualHasMany { use IsDatabaseModel; - public PrimaryKey $id; - #[Virtual] /** @var \Tests\Tempest\Integration\Database\ModelInspector\ModelInspectorTestDtoForModelWithVirtual[] $dto */ public array $dtos; @@ -58,8 +56,6 @@ final class ModelInspectorTestModelWithVirtualDto { use IsDatabaseModel; - public PrimaryKey $id; - #[Virtual] public ModelInspectorTestDtoForModelWithVirtual $dto; } @@ -77,8 +73,6 @@ final class ModelInspectorTestModelWithSerializedDto { use IsDatabaseModel; - public PrimaryKey $id; - public ModelInspectorTestDtoForModelWithSerializer $dto; } @@ -93,8 +87,6 @@ final class ModelInspectorTestModelWithSerializedDtoProperty { use IsDatabaseModel; - public PrimaryKey $id; - #[SerializeWith(DtoSerializer::class)] public ModelInspectorTestDtoForModelWithSerializerOnProperty $dto; } diff --git a/tests/Integration/Database/ModelInspector/ModelWithDtoTest.php b/tests/Integration/Database/ModelInspector/ModelWithDtoTest.php index 19b180618..8870dcf61 100644 --- a/tests/Integration/Database/ModelInspector/ModelWithDtoTest.php +++ b/tests/Integration/Database/ModelInspector/ModelWithDtoTest.php @@ -65,7 +65,5 @@ final class ModelWithDtoTestModelWithSerializedDto { use IsDatabaseModel; - public PrimaryKey $id; - public ModelWithDtoTestDtoForModelWithSerializer $dto; } diff --git a/tests/Integration/Database/ModelsWithoutIdTest.php b/tests/Integration/Database/ModelsWithoutIdTest.php index dc3064288..f75659bdf 100644 --- a/tests/Integration/Database/ModelsWithoutIdTest.php +++ b/tests/Integration/Database/ModelsWithoutIdTest.php @@ -8,7 +8,6 @@ use Tempest\Database\DatabaseMigration; use Tempest\Database\Exceptions\ModelDidNotHavePrimaryColumn; use Tempest\Database\HasOne; -use Tempest\Database\IsDatabaseModel; use Tempest\Database\Migrations\CreateMigrationsTable; use Tempest\Database\PrimaryKey; use Tempest\Database\QueryStatement; @@ -23,39 +22,6 @@ */ final class ModelsWithoutIdTest extends FrameworkIntegrationTestCase { - public function test_save_creates_new_record_for_model_without_id(): void - { - $this->migrate(CreateMigrationsTable::class, CreateLogEntryMigration::class); - - $log = new LogEntry(level: 'INFO', message: 'Frieren discovered ancient magic', context: 'exploration'); - $savedLog = $log->save(); - - $this->assertSame($log, $savedLog); - $this->assertSame('INFO', $savedLog->level); - $this->assertSame('Frieren discovered ancient magic', $savedLog->message); - - $allLogs = query(LogEntry::class)->all(); - $this->assertCount(1, $allLogs); - $this->assertSame('INFO', $allLogs[0]->level); - } - - public function test_save_always_inserts_for_models_without_id(): void - { - $this->migrate(CreateMigrationsTable::class, CreateLogEntryMigration::class); - - $log = new LogEntry(level: 'INFO', message: 'Original message', context: 'test'); - $log->save(); - - // Models without primary keys always insert when save() is called - $log->message = 'Modified message'; - $log->save(); - - $allLogs = query(LogEntry::class)->all(); - $this->assertCount(2, $allLogs); - $this->assertSame('Original message', $allLogs[0]->message); - $this->assertSame('Modified message', $allLogs[1]->message); - } - public function test_update_model_without_id_with_specific_conditions(): void { $this->migrate(CreateMigrationsTable::class, CreateLogEntryMigration::class); @@ -166,13 +132,11 @@ public function test_model_with_mixed_id_and_non_id_properties(): void { $this->migrate(CreateMigrationsTable::class, CreateMixedModelMigration::class); - $mixed = new MixedModel( + $mixed = query(MixedModel::class)->create( regular_field: 'test', another_field: 'data', ); - $mixed->save(); - $this->assertInstanceOf(PrimaryKey::class, $mixed->id); $this->assertSame('test', $mixed->regular_field); @@ -181,188 +145,10 @@ public function test_model_with_mixed_id_and_non_id_properties(): void $this->assertInstanceOf(PrimaryKey::class, $all[0]->id); $this->assertSame('test', $all[0]->regular_field); } - - public function test_refresh_throws_for_models_without_id(): void - { - $this->migrate(CreateMigrationsTable::class, CreateLogEntryMigration::class); - - $log = new LogEntry( - level: 'INFO', - message: 'Frieren studies magic', - context: 'training', - ); - - $this->expectException(ModelDidNotHavePrimaryColumn::class); - $this->expectExceptionMessage('does not have a primary column defined, which is required for the `refresh` method'); - - $log->refresh(); - } - - public function test_load_throws_for_models_without_id(): void - { - $this->migrate(CreateMigrationsTable::class, CreateLogEntryMigration::class); - - $log = new LogEntry( - level: 'INFO', - message: 'Frieren explores ruins', - context: 'adventure', - ); - - $this->expectException(ModelDidNotHavePrimaryColumn::class); - $this->expectExceptionMessage('does not have a primary column defined, which is required for the `load` method'); - - $log->load('someRelation'); - } - - public function test_refresh_works_for_models_with_id(): void - { - $this->migrate(CreateMigrationsTable::class, CreateMixedModelMigration::class); - - $mixed = query(MixedModel::class)->create( - regular_field: 'original', - another_field: 'data', - ); - - query(MixedModel::class) - ->update(regular_field: 'updated') - ->where('id', $mixed->id->value) - ->execute(); - - $mixed->refresh(); - - $this->assertSame('updated', $mixed->regular_field); - $this->assertSame('data', $mixed->another_field); - } - - public function test_refresh_works_for_models_with_unloaded_relation(): void - { - $this->migrate( - CreateMigrationsTable::class, - CreateTestUserMigration::class, - CreateTestProfileMigration::class, - ); - - $user = query(TestUser::class)->create( - name: 'Frieren', - email: 'frieren@magic.elf', - ); - - query(TestProfile::class)->create( - user: $user, - bio: 'Ancient elf mage', - age: 1000, - ); - - // Get user without loading the profile relation - $userWithoutProfile = query(TestUser::class)->findById($user->id); - - $this->assertNull($userWithoutProfile->profile); - - // Update the user's name in the database - query(TestUser::class) - ->update(name: 'Frieren the Mage') - ->where('id', $user->id->value) - ->execute(); - - // Refresh should work even with unloaded relations - $userWithoutProfile->refresh(); - - $this->assertSame('Frieren the Mage', $userWithoutProfile->name); - $this->assertSame('frieren@magic.elf', $userWithoutProfile->email); - $this->assertNull($userWithoutProfile->profile); // Relation should still be unloaded - - // Load the relation - $userWithoutProfile->load('profile'); - - $this->assertInstanceOf(TestProfile::class, $userWithoutProfile->profile); - $this->assertSame('Ancient elf mage', $userWithoutProfile->profile->bio); - $this->assertSame(1000, $userWithoutProfile->profile->age); - - $userWithoutProfile->refresh(); - - $this->assertInstanceOf(TestProfile::class, $userWithoutProfile->profile); - } - - public function test_load_works_for_models_with_id(): void - { - $this->migrate(CreateMigrationsTable::class, CreateMixedModelMigration::class); - - $mixed = query(MixedModel::class)->create(regular_field: 'test', another_field: 'data'); - $result = $mixed->load(); - - $this->assertSame($mixed, $result); - $this->assertSame('test', $mixed->regular_field); - } - - public function test_load_with_relation_works_for_models_with_id(): void - { - $this->migrate( - CreateMigrationsTable::class, - CreateTestUserMigration::class, - CreateTestProfileMigration::class, - ); - - $user = query(TestUser::class)->create( - name: 'Frieren', - email: 'frieren@magic.elf', - ); - - query(TestProfile::class)->create( - user: $user, - bio: 'Ancient elf mage who loves magic and collecting spells', - age: 1000, - ); - - $userWithProfile = $user->load('profile'); - - $this->assertSame($user, $userWithProfile); - $this->assertSame('Frieren', $user->name); - $this->assertInstanceOf(TestProfile::class, $user->profile); - $this->assertSame('Ancient elf mage who loves magic and collecting spells', $user->profile->bio); - $this->assertSame(1000, $user->profile->age); - } - - // this may be a bug, but I'm adding a test just to be sure we don't break the behavior by mistake. - // I believe ->load should just load the specified relations, but it also reloads all properties - public function test_load_method_refreshes_all_properties_not_just_relations(): void - { - $this->migrate( - CreateMigrationsTable::class, - CreateTestUserMigration::class, - CreateTestProfileMigration::class, - ); - - $user = query(TestUser::class)->create( - name: 'Frieren', - email: 'frieren@magic.elf', - ); - - query(TestProfile::class)->create( - user: $user, - bio: 'Ancient elf mage', - age: 1000, - ); - - $userInstance = query(TestUser::class)->findById($user->id); - $userInstance->name = 'Fern'; - - query(TestUser::class) - ->update(email: 'updated@magic.elf') - ->where('id', $user->id->value) - ->execute(); - - $userInstance->load('profile'); - - $this->assertSame('Frieren', $userInstance->name); // "Fern" was discarded here - $this->assertSame('updated@magic.elf', $userInstance->email); - $this->assertInstanceOf(TestProfile::class, $userInstance->profile); - } } final class LogEntry { - use IsDatabaseModel; - public function __construct( public string $level, public string $message, @@ -373,8 +159,6 @@ public function __construct( #[Table('cache_entries')] final class CacheEntry { - use IsDatabaseModel; - public function __construct( public string $cache_key, public string $cache_value, @@ -384,8 +168,6 @@ public function __construct( final class MixedModel { - use IsDatabaseModel; - public ?PrimaryKey $id = null; public function __construct( @@ -396,8 +178,6 @@ public function __construct( final class TestUser { - use IsDatabaseModel; - public ?PrimaryKey $id = null; #[HasOne(ownerJoin: 'user_id')] @@ -411,8 +191,6 @@ public function __construct( final class TestProfile { - use IsDatabaseModel; - public ?PrimaryKey $id = null; #[BelongsTo(ownerJoin: 'user_id')] diff --git a/tests/Integration/Database/QueryStatements/User.php b/tests/Integration/Database/QueryStatements/User.php index ba9d09dee..efd542154 100644 --- a/tests/Integration/Database/QueryStatements/User.php +++ b/tests/Integration/Database/QueryStatements/User.php @@ -11,8 +11,6 @@ final class User { use IsDatabaseModel; - public PrimaryKey $id; - public string $name; public string $email; diff --git a/tests/Integration/Database/RefreshModelTest.php b/tests/Integration/Database/RefreshModelTest.php new file mode 100644 index 000000000..82f8ddf8d --- /dev/null +++ b/tests/Integration/Database/RefreshModelTest.php @@ -0,0 +1,104 @@ +migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + CreateChapterTable::class, + ); + + $author = Author::create( + name: 'Brent Roose', + ); + + $book = Book::create( + title: 'Timeline Taxi', + author: $author, + ); + + // Get user without loading the profile relation + $book = Book::get($book->id); + + $this->assertFalse(isset($book->author)); + + // Update the user's name in the database + query(Book::class) + ->update( + title: 'Timeline Taxi 2', + ) + ->where('id', $book->id) + ->execute(); + + // Refresh should work even with unloaded relations + $book->refresh(); + + $this->assertSame('Timeline Taxi 2', $book->title); + $this->assertFalse(isset($book->author)); // Relation should still be unloaded + + // Load the relation + $book->load('author'); + + $this->assertTrue(isset($book->author)); + + $book->refresh(); + + $this->assertTrue(isset($book->author)); + } + + public function test_load_method_only_refreshes_relations_and_nothing_else(): void + { + $this->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + CreateChapterTable::class, + ); + + $author = Author::create( + name: 'Brent Roose', + ); + + $book = Book::create( + title: 'Timeline Taxi', + author: $author, + ); + + // Get user without loading the profile relation + $book = Book::get($book->id); + + $this->assertFalse(isset($book->author)); + + // Update the user's name in the database + query(Book::class) + ->update( + title: 'Timeline Taxi 2', + ) + ->where('id', $book->id) + ->execute(); + + // Load the relation + $book->load('author'); + + // The updated value from the database is ignored + $this->assertSame('Timeline Taxi', $book->title); + } +} diff --git a/tests/Integration/Http/ValidationResponseTest.php b/tests/Integration/Http/ValidationResponseTest.php index 14e6d96a9..c2ea2b4f7 100644 --- a/tests/Integration/Http/ValidationResponseTest.php +++ b/tests/Integration/Http/ValidationResponseTest.php @@ -9,6 +9,7 @@ use Tests\Tempest\Fixtures\Controllers\ValidationController; use Tests\Tempest\Fixtures\Migrations\CreateAuthorTable; use Tests\Tempest\Fixtures\Migrations\CreateBookTable; +use Tests\Tempest\Fixtures\Migrations\CreateChapterTable; use Tests\Tempest\Fixtures\Migrations\CreatePublishersTable; use Tests\Tempest\Fixtures\Modules\Books\Models\Author; use Tests\Tempest\Fixtures\Modules\Books\Models\Book; @@ -57,6 +58,7 @@ public function test_update_book(): void CreatePublishersTable::class, CreateAuthorTable::class, CreateBookTable::class, + CreateChapterTable::class, ); $book = Book::create( diff --git a/tests/Integration/Mapper/Fixtures/ObjectFactoryA.php b/tests/Integration/Mapper/Fixtures/ObjectFactoryA.php index 99ebc301c..89f2a91f2 100644 --- a/tests/Integration/Mapper/Fixtures/ObjectFactoryA.php +++ b/tests/Integration/Mapper/Fixtures/ObjectFactoryA.php @@ -12,8 +12,6 @@ final class ObjectFactoryA { use IsDatabaseModel; - public PrimaryKey $id; - #[CastWith(ObjectFactoryACaster::class)] public string $prop; } diff --git a/tests/Integration/Mapper/Fixtures/ObjectFactoryWithValidation.php b/tests/Integration/Mapper/Fixtures/ObjectFactoryWithValidation.php index ef561185a..8ed782081 100644 --- a/tests/Integration/Mapper/Fixtures/ObjectFactoryWithValidation.php +++ b/tests/Integration/Mapper/Fixtures/ObjectFactoryWithValidation.php @@ -12,8 +12,6 @@ final class ObjectFactoryWithValidation { use IsDatabaseModel; - public PrimaryKey $id; - #[HasLength(min: 2)] public string $prop; } diff --git a/tests/Integration/Route/RouterTest.php b/tests/Integration/Route/RouterTest.php index 80d704237..110e729f0 100644 --- a/tests/Integration/Route/RouterTest.php +++ b/tests/Integration/Route/RouterTest.php @@ -11,6 +11,7 @@ use ReflectionException; use Tempest\Core\AppConfig; use Tempest\Database\Migrations\CreateMigrationsTable; +use Tempest\Database\PrimaryKey; use Tempest\Http\HttpRequestFailed; use Tempest\Http\Responses\Ok; use Tempest\Http\Status; @@ -26,6 +27,7 @@ use Tests\Tempest\Fixtures\Migrations\CreateAuthorTable; use Tests\Tempest\Fixtures\Migrations\CreateBookTable; use Tests\Tempest\Fixtures\Migrations\CreatePublishersTable; +use Tests\Tempest\Fixtures\Modules\Books\BookController; use Tests\Tempest\Fixtures\Modules\Books\Models\Author; use Tests\Tempest\Fixtures\Modules\Books\Models\Book; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; @@ -214,6 +216,30 @@ public function test_generate_uri_with_enum(): void ); } + public function test_generate_uri_with_bindable_model(): void + { + $book = Book::new( + id: new PrimaryKey('abc'), + ); + + $this->assertSame( + '/books/abc', + uri([BookController::class, 'show'], book: $book), + ); + } + + public function test_generate_uri_with_primary_key(): void + { + $book = Book::new( + id: new PrimaryKey('abc'), + ); + + $this->assertSame( + '/books/abc', + uri([BookController::class, 'show'], book: $book->id), + ); + } + public function test_uri_with_query_param_that_collides_partially_with_route_param(): void { $this->assertSame(