From 7415ff40d9aec6a89890aa072c7ba66e7443e6c1 Mon Sep 17 00:00:00 2001 From: brendt Date: Tue, 13 May 2025 14:13:59 +0200 Subject: [PATCH 01/28] wip --- .../QueryBuilders/SelectQueryBuilder.php | 14 +++++ .../src/QueryStatements/JoinStatement.php | 11 +++- .../QueryStatements/JoinStatementTest.php | 63 +++++++++++++++++++ .../Builder/SelectQueryBuilderTest.php | 19 +++++- 4 files changed, 102 insertions(+), 5 deletions(-) create mode 100644 packages/database/tests/QueryStatements/JoinStatementTest.php diff --git a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php index 5857c745d..13764e8f5 100644 --- a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php @@ -37,6 +37,8 @@ final class SelectQueryBuilder implements BuildsQuery private SelectStatement $select; + private array $joins = []; + private array $relations = []; private array $bindings = []; @@ -170,6 +172,14 @@ public function offset(int $offset): self return $this; } + /** @return self */ + public function join(string ...$joins): self + { + $this->joins = [...$this->joins, ...$joins]; + + return $this; + } + /** @return self */ public function with(string ...$relations): self { @@ -203,6 +213,10 @@ public function build(mixed ...$bindings): Query { $resolvedRelations = $this->resolveRelations(); + foreach ($this->joins as $join) { + $this->select->join[] = new JoinStatement($join); + } + foreach ($resolvedRelations as $relation) { $this->select->columns = $this->select->columns->append(...$relation->getFieldDefinitions()->map(fn (FieldDefinition $field) => (string) $field->withAlias())); $this->select->join[] = new JoinStatement($relation->getStatement()); diff --git a/packages/database/src/QueryStatements/JoinStatement.php b/packages/database/src/QueryStatements/JoinStatement.php index 8a915c5c1..fb3409530 100644 --- a/packages/database/src/QueryStatements/JoinStatement.php +++ b/packages/database/src/QueryStatements/JoinStatement.php @@ -4,15 +4,22 @@ use Tempest\Database\Config\DatabaseDialect; use Tempest\Database\QueryStatement; +use function Tempest\Support\str; final readonly class JoinStatement implements QueryStatement { public function __construct( - private string $join, + private string $statement, ) {} public function compile(DatabaseDialect $dialect): string { - return $this->join; + $statement = $this->statement; + + if (! str($statement)->lower()->startsWith(['join', 'inner join', 'left join', 'right join', 'full join', 'full outer join', 'self join'])) { + $statement = sprintf('INNER JOIN %s', $statement); + } + + return $statement; } } diff --git a/packages/database/tests/QueryStatements/JoinStatementTest.php b/packages/database/tests/QueryStatements/JoinStatementTest.php new file mode 100644 index 000000000..a29d16d36 --- /dev/null +++ b/packages/database/tests/QueryStatements/JoinStatementTest.php @@ -0,0 +1,63 @@ +assertSame( + 'INNER JOIN authors on authors.id = books.author_id', + new JoinStatement('authors on authors.id = books.author_id')->compile(DatabaseDialect::MYSQL) + ); + + $this->assertSame( + 'inner join authors on authors.id = books.author_id', + new JoinStatement('inner join authors on authors.id = books.author_id')->compile(DatabaseDialect::MYSQL) + ); + + $this->assertSame( + 'INNER JOIN authors on authors.id = books.author_id', + new JoinStatement('INNER JOIN authors on authors.id = books.author_id')->compile(DatabaseDialect::MYSQL) + ); + + $this->assertSame( + 'LEFT JOIN authors on authors.id = books.author_id', + new JoinStatement('LEFT JOIN authors on authors.id = books.author_id')->compile(DatabaseDialect::MYSQL) + ); + + $this->assertSame( + 'RIGHT JOIN authors on authors.id = books.author_id', + new JoinStatement('RIGHT JOIN authors on authors.id = books.author_id')->compile(DatabaseDialect::MYSQL) + ); + + $this->assertSame( + 'FULL JOIN authors on authors.id = books.author_id', + new JoinStatement('FULL JOIN authors on authors.id = books.author_id')->compile(DatabaseDialect::MYSQL) + ); + + $this->assertSame( + 'JOIN authors on authors.id = books.author_id', + new JoinStatement('JOIN authors on authors.id = books.author_id')->compile(DatabaseDialect::MYSQL) + ); + + $this->assertSame( + 'FULL OUTER JOIN authors on authors.id = books.author_id', + new JoinStatement('FULL OUTER JOIN authors on authors.id = books.author_id')->compile(DatabaseDialect::MYSQL) + ); + + $this->assertSame( + 'FULL JOIN authors on authors.id = books.author_id', + new JoinStatement('FULL JOIN authors on authors.id = books.author_id')->compile(DatabaseDialect::MYSQL) + ); + + $this->assertSame( + 'SELF JOIN authors on authors.id = books.author_id', + new JoinStatement('SELF JOIN authors on authors.id = books.author_id')->compile(DatabaseDialect::MYSQL) + ); + } +} \ No newline at end of file diff --git a/tests/Integration/Database/Builder/SelectQueryBuilderTest.php b/tests/Integration/Database/Builder/SelectQueryBuilderTest.php index 851be9a7d..69494e845 100644 --- a/tests/Integration/Database/Builder/SelectQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/SelectQueryBuilderTest.php @@ -5,10 +5,7 @@ namespace Tests\Tempest\Integration\Database\Builder; use Tempest\Database\Builder\QueryBuilders\SelectQueryBuilder; -use Tempest\Database\Database; use Tempest\Database\Migrations\CreateMigrationsTable; -use Tempest\Database\Query; -use Tempest\Database\QueryStatements\CreateTableStatement; use Tests\Tempest\Fixtures\Migrations\CreateAuthorTable; use Tests\Tempest\Fixtures\Migrations\CreateBookTable; use Tests\Tempest\Fixtures\Modules\Books\Models\Author; @@ -95,6 +92,22 @@ public function test_where_statement(): void $this->assertSame('B', $book->title); } + public function test_join(): void + { + $this->migrate( + CreateMigrationsTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + ); + + $author = Author::new(name: 'Brent')->save(); + Book::new(title: 'A', author: $author)->save(); + + $query = query(Book::class)->select()->join('authors on authors.id = books.author_id')->first(); + + $this->assertSame('Brent', $query->author->name); + } + public function test_order_by(): void { $this->migrate( From c3a816d0dcf7436a477a9bc4f72db6ed233581c1 Mon Sep 17 00:00:00 2001 From: brendt Date: Wed, 14 May 2025 08:54:57 +0200 Subject: [PATCH 02/28] wip --- packages/database/src/Query.php | 1 + .../src/QueryStatements/FieldStatement.php | 34 ++++++++++ .../src/QueryStatements/JoinStatement.php | 1 + .../src/QueryStatements/SelectStatement.php | 23 +++---- .../QueryStatements/FieldStatementTest.php | 62 +++++++++++++++++++ .../QueryStatements/JoinStatementTest.php | 22 +++---- .../QueryStatements/SelectStatementTest.php | 21 +++++-- .../Builder/SelectQueryBuilderTest.php | 10 ++- 8 files changed, 146 insertions(+), 28 deletions(-) create mode 100644 packages/database/src/QueryStatements/FieldStatement.php create mode 100644 packages/database/tests/QueryStatements/FieldStatementTest.php diff --git a/packages/database/src/Query.php b/packages/database/src/Query.php index 97d6a8e4d..a4cc26487 100644 --- a/packages/database/src/Query.php +++ b/packages/database/src/Query.php @@ -36,6 +36,7 @@ public function execute(mixed ...$bindings): Id public function fetch(mixed ...$bindings): array { + lw($this->getSql()); return $this->getDatabase()->fetch($this->withBindings($bindings)); } diff --git a/packages/database/src/QueryStatements/FieldStatement.php b/packages/database/src/QueryStatements/FieldStatement.php new file mode 100644 index 000000000..b28aec1a3 --- /dev/null +++ b/packages/database/src/QueryStatements/FieldStatement.php @@ -0,0 +1,34 @@ +field)) + ->map(function (string $part) use ($dialect) { + return + arr(explode('.', $part)) + ->map(fn (string $part) => trim($part, '` ')) + ->map(fn (string $part) => match ($dialect) { + DatabaseDialect::SQLITE => $part, + default => sprintf('`%s`', $part), + }) + ->implode('.'); + }) + ->implode(' AS '); + } +} diff --git a/packages/database/src/QueryStatements/JoinStatement.php b/packages/database/src/QueryStatements/JoinStatement.php index fb3409530..23ab04d0b 100644 --- a/packages/database/src/QueryStatements/JoinStatement.php +++ b/packages/database/src/QueryStatements/JoinStatement.php @@ -4,6 +4,7 @@ use Tempest\Database\Config\DatabaseDialect; use Tempest\Database\QueryStatement; + use function Tempest\Support\str; final readonly class JoinStatement implements QueryStatement diff --git a/packages/database/src/QueryStatements/SelectStatement.php b/packages/database/src/QueryStatements/SelectStatement.php index 4aeda5907..0450df9f8 100644 --- a/packages/database/src/QueryStatements/SelectStatement.php +++ b/packages/database/src/QueryStatements/SelectStatement.php @@ -29,20 +29,12 @@ public function compile(DatabaseDialect $dialect): string $columns = $this->columns->isEmpty() ? '*' : $this->columns - ->map(function (string|Stringable $column) { + ->map(function (string|Stringable $column) use ($dialect) { if ($column instanceof FieldDefinition) { return (string) $column; } - if (! str_starts_with($column, '`')) { - $column = "`{$column}"; - } - - if (! str_ends_with($column, '`')) { - $column = "{$column}`"; - } - - return (string) $column; + return new FieldStatement($column)->compile($dialect); }) ->implode(', '); @@ -95,6 +87,15 @@ public function compile(DatabaseDialect $dialect): string ->implode(PHP_EOL); } - return $query->implode(PHP_EOL); + $compiled = $query->implode(PHP_EOL); + + /* TODO: this should be improved. + * More specifically, \Tempest\Database\Builder\FieldDefinition should be aware of the dialect, + * or the whole ORM should be refactored to use \Tempest\Database\QueryStatements\FieldStatement*/ +// if ($dialect === DatabaseDialect::SQLITE) { +// $compiled = $compiled->replace('`', ''); +// } + + return $compiled; } } diff --git a/packages/database/tests/QueryStatements/FieldStatementTest.php b/packages/database/tests/QueryStatements/FieldStatementTest.php new file mode 100644 index 000000000..1284e3ec5 --- /dev/null +++ b/packages/database/tests/QueryStatements/FieldStatementTest.php @@ -0,0 +1,62 @@ +assertSame( + 'table.field', + new FieldStatement('table.field')->compile(DatabaseDialect::SQLITE), + ); + + $this->assertSame( + 'table.field', + new FieldStatement('`table`.`field`')->compile(DatabaseDialect::SQLITE), + ); + } + + public function test_mysql(): void + { + $this->assertSame( + '`table`.`field`', + new FieldStatement('`table`.`field`')->compile(DatabaseDialect::MYSQL), + ); + + $this->assertSame( + '`table`.`field`', + new FieldStatement('table.field')->compile(DatabaseDialect::MYSQL), + ); + } + + public function test_postgres(): void + { + $this->assertSame( + '`table`.`field`', + new FieldStatement('`table`.`field`')->compile(DatabaseDialect::POSTGRESQL), + ); + + $this->assertSame( + '`table`.`field`', + new FieldStatement('table.field')->compile(DatabaseDialect::POSTGRESQL), + ); + } + + public function test_with_as(): void + { + $this->assertSame( + 'parent.through[].id AS parent.through[].id', + new FieldStatement('`parent.through[]`.`id` AS `parent.through[].id`')->compile(DatabaseDialect::SQLITE), + ); + + $this->assertSame( + '`parent.through[]`.`id` AS `parent.through[].id`', + new FieldStatement('`parent.through[]`.`id` AS `parent.through[].id`')->compile(DatabaseDialect::MYSQL), + ); + } +} diff --git a/packages/database/tests/QueryStatements/JoinStatementTest.php b/packages/database/tests/QueryStatements/JoinStatementTest.php index a29d16d36..895a14528 100644 --- a/packages/database/tests/QueryStatements/JoinStatementTest.php +++ b/packages/database/tests/QueryStatements/JoinStatementTest.php @@ -12,52 +12,52 @@ public function test_inner_join_is_added_when_needed(): void { $this->assertSame( 'INNER JOIN authors on authors.id = books.author_id', - new JoinStatement('authors on authors.id = books.author_id')->compile(DatabaseDialect::MYSQL) + new JoinStatement('authors on authors.id = books.author_id')->compile(DatabaseDialect::MYSQL), ); $this->assertSame( 'inner join authors on authors.id = books.author_id', - new JoinStatement('inner join authors on authors.id = books.author_id')->compile(DatabaseDialect::MYSQL) + new JoinStatement('inner join authors on authors.id = books.author_id')->compile(DatabaseDialect::MYSQL), ); $this->assertSame( 'INNER JOIN authors on authors.id = books.author_id', - new JoinStatement('INNER JOIN authors on authors.id = books.author_id')->compile(DatabaseDialect::MYSQL) + new JoinStatement('INNER JOIN authors on authors.id = books.author_id')->compile(DatabaseDialect::MYSQL), ); $this->assertSame( 'LEFT JOIN authors on authors.id = books.author_id', - new JoinStatement('LEFT JOIN authors on authors.id = books.author_id')->compile(DatabaseDialect::MYSQL) + new JoinStatement('LEFT JOIN authors on authors.id = books.author_id')->compile(DatabaseDialect::MYSQL), ); $this->assertSame( 'RIGHT JOIN authors on authors.id = books.author_id', - new JoinStatement('RIGHT JOIN authors on authors.id = books.author_id')->compile(DatabaseDialect::MYSQL) + new JoinStatement('RIGHT JOIN authors on authors.id = books.author_id')->compile(DatabaseDialect::MYSQL), ); $this->assertSame( 'FULL JOIN authors on authors.id = books.author_id', - new JoinStatement('FULL JOIN authors on authors.id = books.author_id')->compile(DatabaseDialect::MYSQL) + new JoinStatement('FULL JOIN authors on authors.id = books.author_id')->compile(DatabaseDialect::MYSQL), ); $this->assertSame( 'JOIN authors on authors.id = books.author_id', - new JoinStatement('JOIN authors on authors.id = books.author_id')->compile(DatabaseDialect::MYSQL) + new JoinStatement('JOIN authors on authors.id = books.author_id')->compile(DatabaseDialect::MYSQL), ); $this->assertSame( 'FULL OUTER JOIN authors on authors.id = books.author_id', - new JoinStatement('FULL OUTER JOIN authors on authors.id = books.author_id')->compile(DatabaseDialect::MYSQL) + new JoinStatement('FULL OUTER JOIN authors on authors.id = books.author_id')->compile(DatabaseDialect::MYSQL), ); $this->assertSame( 'FULL JOIN authors on authors.id = books.author_id', - new JoinStatement('FULL JOIN authors on authors.id = books.author_id')->compile(DatabaseDialect::MYSQL) + new JoinStatement('FULL JOIN authors on authors.id = books.author_id')->compile(DatabaseDialect::MYSQL), ); $this->assertSame( 'SELF JOIN authors on authors.id = books.author_id', - new JoinStatement('SELF JOIN authors on authors.id = books.author_id')->compile(DatabaseDialect::MYSQL) + new JoinStatement('SELF JOIN authors on authors.id = books.author_id')->compile(DatabaseDialect::MYSQL), ); } -} \ No newline at end of file +} diff --git a/packages/database/tests/QueryStatements/SelectStatementTest.php b/packages/database/tests/QueryStatements/SelectStatementTest.php index 76c51d613..e2583d788 100644 --- a/packages/database/tests/QueryStatements/SelectStatementTest.php +++ b/packages/database/tests/QueryStatements/SelectStatementTest.php @@ -33,7 +33,7 @@ public function test_select(): void offset: 100, ); - $expected = <<assertSame($expected, $statement->compile(DatabaseDialect::MYSQL)); - $this->assertSame($expected, $statement->compile(DatabaseDialect::POSTGRESQL)); - $this->assertSame($expected, $statement->compile(DatabaseDialect::SQLITE)); + $this->assertSame($expectedWithBackticks, $statement->compile(DatabaseDialect::MYSQL)); + $this->assertSame($expectedWithBackticks, $statement->compile(DatabaseDialect::POSTGRESQL)); + + $expectedWithoutBackticks = <<assertSame($expectedWithoutBackticks, $statement->compile(DatabaseDialect::SQLITE)); } } diff --git a/tests/Integration/Database/Builder/SelectQueryBuilderTest.php b/tests/Integration/Database/Builder/SelectQueryBuilderTest.php index 69494e845..27ed41c30 100644 --- a/tests/Integration/Database/Builder/SelectQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/SelectQueryBuilderTest.php @@ -103,9 +103,15 @@ public function test_join(): void $author = Author::new(name: 'Brent')->save(); Book::new(title: 'A', author: $author)->save(); - $query = query(Book::class)->select()->join('authors on authors.id = books.author_id')->first(); + $query = query('books')->select('books.title AS book_title', 'authors.name')->join('authors on authors.id = books.author_id'); - $this->assertSame('Brent', $query->author->name); + $this->assertSame( + [ + 'book_title' => 'A', + 'name' => 'Brent', + ], + $query->first(), + ); } public function test_order_by(): void From 900a87dc46bb180525dcc2ef4d565575894635fb Mon Sep 17 00:00:00 2001 From: brendt Date: Wed, 14 May 2025 09:12:53 +0200 Subject: [PATCH 03/28] wip --- .../QueryBuilders/SelectQueryBuilder.php | 6 +- ...odelMapper.php => DatabaseModelMapper.php} | 6 +- tests/Fixtures/Migrations/CreateIsbnTable.php | 30 ++++++++ .../Mappers/DatabaseModelMapperTest.php | 73 +++++++++++++++++++ 4 files changed, 111 insertions(+), 4 deletions(-) rename packages/database/src/Mappers/{QueryToModelMapper.php => DatabaseModelMapper.php} (98%) create mode 100644 tests/Fixtures/Migrations/CreateIsbnTable.php create mode 100644 tests/Integration/Database/Mappers/DatabaseModelMapperTest.php diff --git a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php index 13764e8f5..e0dcb5d69 100644 --- a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php @@ -9,6 +9,7 @@ use Tempest\Database\Builder\ModelDefinition; use Tempest\Database\Builder\TableDefinition; use Tempest\Database\Id; +use Tempest\Database\Mappers\DatabaseModelMapper; use Tempest\Database\Query; use Tempest\Database\QueryStatements\JoinStatement; use Tempest\Database\QueryStatements\OrderByStatement; @@ -65,7 +66,10 @@ public function first(mixed ...$bindings): mixed return $query->fetchFirst(); } - $result = map($query)->collection()->to($this->modelClass); + $result = map($query) + ->collection() + ->with(DatabaseModelMapper::class) + ->to($this->modelClass); if ($result === []) { return null; diff --git a/packages/database/src/Mappers/QueryToModelMapper.php b/packages/database/src/Mappers/DatabaseModelMapper.php similarity index 98% rename from packages/database/src/Mappers/QueryToModelMapper.php rename to packages/database/src/Mappers/DatabaseModelMapper.php index 23fc01c01..721c77d3d 100644 --- a/packages/database/src/Mappers/QueryToModelMapper.php +++ b/packages/database/src/Mappers/DatabaseModelMapper.php @@ -5,13 +5,13 @@ namespace Tempest\Database\Mappers; use Tempest\Database\Builder\ModelDefinition; -use Tempest\Database\Query; use Tempest\Mapper\CasterFactory; use Tempest\Mapper\Mapper; use Tempest\Reflection\ClassReflector; use Tempest\Reflection\PropertyReflector; -final readonly class QueryToModelMapper implements Mapper +// TODO: refactor +final readonly class DatabaseModelMapper implements Mapper { public function __construct( private CasterFactory $casterFactory, @@ -19,7 +19,7 @@ public function __construct( public function canMap(mixed $from, mixed $to): bool { - return $from instanceof Query; + return false; } public function map(mixed $from, mixed $to): array diff --git a/tests/Fixtures/Migrations/CreateIsbnTable.php b/tests/Fixtures/Migrations/CreateIsbnTable.php new file mode 100644 index 000000000..6dd7631b0 --- /dev/null +++ b/tests/Fixtures/Migrations/CreateIsbnTable.php @@ -0,0 +1,30 @@ +primary() + ->text('value') + ->belongsTo('isbns.book_id', 'books.id'); + } + + public function down(): QueryStatement + { + return DropTableStatement::forModel(Book::class); + } +} diff --git a/tests/Integration/Database/Mappers/DatabaseModelMapperTest.php b/tests/Integration/Database/Mappers/DatabaseModelMapperTest.php new file mode 100644 index 000000000..9eaf92105 --- /dev/null +++ b/tests/Integration/Database/Mappers/DatabaseModelMapperTest.php @@ -0,0 +1,73 @@ +seed(); + + $query = query('books') + ->select( + 'books.title', + 'authors.name', + ) + ->build() + ; + } + + private function seed(): void + { + $this->migrate( + CreateMigrationsTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + CreateChapterTable::class, + CreateIsbnTable::class, + ); + + query('authors')->insert( + ['name' => 'Brent'], + ['name' => 'Tolkien'], + )->execute(); + + query('books')->insert( + ['title' => 'LOTR 1', 'author_id' => 2], + ['title' => 'LOTR 2', 'author_id' => 2], + ['title' => 'LOTR 3', 'author_id' => 2], + ['title' => 'Timeline Taxi', 'author_id' => 1], + )->execute(); + + query('isbns')->insert( + ['value' => 'lotr-1', 'book_id' => 1], + ['value' => 'lotr-2', 'book_id' => 2], + ['value' => 'lotr-3', 'book_id' => 3], + ['value' => 'tt', 'book_id' => 4], + )->execute(); + + query('chapters')->insert( + ['title' => 'LOTR 1.1', 'book_id' => 1], + ['title' => 'LOTR 1.2', 'book_id' => 1], + ['title' => 'LOTR 1.3', 'book_id' => 1], + ['title' => 'LOTR 2.1', 'book_id' => 2], + ['title' => 'LOTR 2.2', 'book_id' => 2], + ['title' => 'LOTR 2.3', 'book_id' => 2], + ['title' => 'LOTR 3.1', 'book_id' => 3], + ['title' => 'LOTR 3.2', 'book_id' => 3], + ['title' => 'LOTR 3.3', 'book_id' => 3], + ['title' => 'Timeline Taxi Chapter 1', 'book_id' => 4], + ['title' => 'Timeline Taxi Chapter 2', 'book_id' => 4], + ['title' => 'Timeline Taxi Chapter 3', 'book_id' => 4], + ['title' => 'Timeline Taxi Chapter 4', 'book_id' => 4], + )->execute(); + } +} \ No newline at end of file From 54c597cc188e138d10b7700a188cbde3b8e726b9 Mon Sep 17 00:00:00 2001 From: brendt Date: Wed, 14 May 2025 10:31:02 +0200 Subject: [PATCH 04/28] wip --- packages/database/src/BelongsTo.php | 6 +- .../database/src/Builder/ModelInspector.php | 91 ++++++++-- .../QueryBuilders/InsertQueryBuilder.php | 6 +- .../QueryBuilders/SelectQueryBuilder.php | 4 +- .../QueryBuilders/UpdateQueryBuilder.php | 4 +- packages/database/src/HasMany.php | 10 +- packages/database/src/HasOne.php | 6 +- ...ModelMapper.php => QueryToModelMapper.php} | 2 +- .../src/Mappers/SelectModelMapper.php | 80 +++++++++ packages/database/src/Query.php | 1 - .../src/QueryStatements/FieldStatement.php | 31 ++-- .../QueryStatements/FieldStatementTest.php | 13 +- packages/reflection/src/HasAttributes.php | 20 ++- packages/reflection/src/PropertyAttribute.php | 11 ++ ...AttributeImplementingPropertyAttribute.php | 13 ++ ...AttributeImplementingPropertyAttribute.php | 9 + .../tests/Fixtures/HasAttributeTest.php | 18 ++ .../support/src/Str/ManipulatesString.php | 8 + packages/support/src/Str/functions.php | 12 ++ .../Mappers/DatabaseModelMapperTest.php | 165 +++++++++++++++++- tests/Integration/Support/StringTest.php | 8 + 21 files changed, 467 insertions(+), 51 deletions(-) rename packages/database/src/Mappers/{DatabaseModelMapper.php => QueryToModelMapper.php} (99%) create mode 100644 packages/database/src/Mappers/SelectModelMapper.php create mode 100644 packages/reflection/src/PropertyAttribute.php create mode 100644 packages/reflection/tests/Fixtures/AttributeImplementingPropertyAttribute.php create mode 100644 packages/reflection/tests/Fixtures/ClassWithPropertyWithAttributeImplementingPropertyAttribute.php create mode 100644 packages/reflection/tests/Fixtures/HasAttributeTest.php diff --git a/packages/database/src/BelongsTo.php b/packages/database/src/BelongsTo.php index f810324d3..f41b504c8 100644 --- a/packages/database/src/BelongsTo.php +++ b/packages/database/src/BelongsTo.php @@ -5,10 +5,14 @@ namespace Tempest\Database; use Attribute; +use Tempest\Reflection\PropertyAttribute; +use Tempest\Reflection\PropertyReflector; #[Attribute(Attribute::TARGET_PROPERTY)] -final readonly class BelongsTo +final class BelongsTo implements PropertyAttribute { + public PropertyReflector $property; + public function __construct( public string $localPropertyName, public string $inversePropertyName = 'id', diff --git a/packages/database/src/Builder/ModelInspector.php b/packages/database/src/Builder/ModelInspector.php index c387272b0..a8730fda4 100644 --- a/packages/database/src/Builder/ModelInspector.php +++ b/packages/database/src/Builder/ModelInspector.php @@ -3,7 +3,9 @@ namespace Tempest\Database\Builder; use ReflectionException; +use Tempest\Database\BelongsTo; use Tempest\Database\Config\DatabaseConfig; +use Tempest\Database\HasMany; use Tempest\Database\HasOne; use Tempest\Database\Table; use Tempest\Reflection\ClassReflector; @@ -12,6 +14,7 @@ use Tempest\Validation\Validator; use function Tempest\get; +use function Tempest\Support\str; final class ModelInspector { @@ -19,7 +22,8 @@ final class ModelInspector public function __construct( private object|string $model, - ) { + ) + { if ($this->model instanceof ClassReflector) { $this->modelClass = $this->model; } else { @@ -70,7 +74,7 @@ public function getPropertyValues(): array continue; } - if ($this->isHasManyRelation($property->getName()) || $this->isHasOneRelation($property->getName())) { + if ($this->getHasMany($property->getName()) || $this->getHasOne($property->getName())) { continue; } @@ -82,42 +86,91 @@ public function getPropertyValues(): array return $values; } - public function isHasManyRelation(string $name): bool + public function getBelongsTo(string $name): ?BelongsTo { if (! $this->isObjectModel()) { - return false; + return null; + } + + $singularizedName = str($name)->singularizeLastWord(); + + if (! $singularizedName->equals($name)) { + return $this->getBelongsTo($singularizedName); } if (! $this->modelClass->hasProperty($name)) { - return false; + return null; } $property = $this->modelClass->getProperty($name); - if ($property->getIterableType()?->isRelation()) { - return true; + if ($belongsTo = $property->getAttribute(BelongsTo::class)) { + return $belongsTo; + } + + if (! $property->getType()->isRelation()) { + return null; + } + + if ($property->hasAttribute(HasOne::class)) { + return null; } - return false; + $belongsTo = new BelongsTo($property->getName()); + $belongsTo->property = $property; + + return $belongsTo; } - public function isHasOneRelation(string $name): bool + public function getHasOne(string $name): ?HasOne { if (! $this->isObjectModel()) { - return false; + return null; + } + + $singularizedName = str($name)->singularizeLastWord(); + + if (! $singularizedName->equals($name)) { + return $this->getHasOne($singularizedName); } if (! $this->modelClass->hasProperty($name)) { - return false; + return null; } $property = $this->modelClass->getProperty($name); - if ($property->hasAttribute(HasOne::class)) { - return true; + if ($hasOne = $property->getAttribute(HasOne::class)) { + return $hasOne; + } + + return null; + } + + public function getHasMany(string $name): ?HasMany + { + if (! $this->isObjectModel()) { + return null; + } + + if (! $this->modelClass->hasProperty($name)) { + return null; + } + + $property = $this->modelClass->getProperty($name); + + if ($hasMany = $property->getAttribute(HasMany::class)) { + return $hasMany; } - return false; + if (! $property->getIterableType()?->isRelation()) { + return null; + } + + $hasMany = new HasMany(inversePropertyName: $property->getName()); + $hasMany->property = $property; + + return $hasMany; } public function validate(mixed ...$data): void @@ -159,4 +212,14 @@ public function getName(): string return $this->modelClass; } + + public function getPrimaryKey(): string + { + return 'id'; + } + + public function getPrimaryField(): string + { + return $this->getTableDefinition()->name . '.' . $this->getPrimaryKey(); + } } diff --git a/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php index 6f37f3fdf..699b671ee 100644 --- a/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php @@ -51,11 +51,11 @@ public function build(mixed ...$bindings): Query foreach ($this->resolveData() as $data) { foreach ($data as $key => $value) { - if ($definition->isHasManyRelation($key)) { + if ($definition->getHasMany($key)) { throw new CannotInsertHasManyRelation($definition->getName(), $key); } - if ($definition->isHasOneRelation($key)) { + if ($definition->getHasOne($key)) { throw new CannotInsertHasOneRelation($definition->getName(), $key); } @@ -104,7 +104,7 @@ private function resolveData(): array } // HasMany and HasOne relations are skipped - if ($definition->isHasManyRelation($property->getName()) || $definition->isHasOneRelation($property->getName())) { + if ($definition->getHasMany($property->getName()) || $definition->getHasOne($property->getName())) { continue; } diff --git a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php index e0dcb5d69..df03c19a1 100644 --- a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php @@ -9,7 +9,7 @@ use Tempest\Database\Builder\ModelDefinition; use Tempest\Database\Builder\TableDefinition; use Tempest\Database\Id; -use Tempest\Database\Mappers\DatabaseModelMapper; +use Tempest\Database\Mappers\QueryToModelMapper; use Tempest\Database\Query; use Tempest\Database\QueryStatements\JoinStatement; use Tempest\Database\QueryStatements\OrderByStatement; @@ -68,7 +68,7 @@ public function first(mixed ...$bindings): mixed $result = map($query) ->collection() - ->with(DatabaseModelMapper::class) + ->with(QueryToModelMapper::class) ->to($this->modelClass); if ($result === []) { diff --git a/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php index d6a49c438..8056f5220 100644 --- a/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php @@ -100,11 +100,11 @@ private function resolveValues(): ImmutableArray foreach ($this->values as $column => $value) { $property = $modelClass->getProperty($column); - if ($modelDefinition->isHasManyRelation($property->getName())) { + if ($modelDefinition->getHasMany($property->getName())) { throw new CannotUpdateHasManyRelation($modelClass->getName(), $property->getName()); } - if ($modelDefinition->isHasOneRelation($property->getName())) { + if ($modelDefinition->getHasOne($property->getName())) { throw new CannotUpdateHasOneRelation($modelClass->getName(), $property->getName()); } diff --git a/packages/database/src/HasMany.php b/packages/database/src/HasMany.php index 42679a647..b8d1a91f2 100644 --- a/packages/database/src/HasMany.php +++ b/packages/database/src/HasMany.php @@ -5,10 +5,18 @@ namespace Tempest\Database; use Attribute; +use Tempest\Reflection\PropertyAttribute; +use Tempest\Reflection\PropertyReflector; #[Attribute(Attribute::TARGET_PROPERTY)] -final readonly class HasMany +final class HasMany implements PropertyAttribute { + public PropertyReflector $property; + + public string $fieldName { + get => $this->property->getName() . '.' . $this->localPropertyName; + } + /** @param null|class-string $inverseClassName */ public function __construct( public string $inversePropertyName, diff --git a/packages/database/src/HasOne.php b/packages/database/src/HasOne.php index e82960917..f9951adcb 100644 --- a/packages/database/src/HasOne.php +++ b/packages/database/src/HasOne.php @@ -5,10 +5,14 @@ namespace Tempest\Database; use Attribute; +use Tempest\Reflection\PropertyAttribute; +use Tempest\Reflection\PropertyReflector; #[Attribute(Attribute::TARGET_PROPERTY)] -final readonly class HasOne +final class HasOne implements PropertyAttribute { + public PropertyReflector $property; + public function __construct( public ?string $inversePropertyName = null, ) {} diff --git a/packages/database/src/Mappers/DatabaseModelMapper.php b/packages/database/src/Mappers/QueryToModelMapper.php similarity index 99% rename from packages/database/src/Mappers/DatabaseModelMapper.php rename to packages/database/src/Mappers/QueryToModelMapper.php index 721c77d3d..667e51574 100644 --- a/packages/database/src/Mappers/DatabaseModelMapper.php +++ b/packages/database/src/Mappers/QueryToModelMapper.php @@ -11,7 +11,7 @@ use Tempest\Reflection\PropertyReflector; // TODO: refactor -final readonly class DatabaseModelMapper implements Mapper +final readonly class QueryToModelMapper implements Mapper { public function __construct( private CasterFactory $casterFactory, diff --git a/packages/database/src/Mappers/SelectModelMapper.php b/packages/database/src/Mappers/SelectModelMapper.php new file mode 100644 index 000000000..9761839af --- /dev/null +++ b/packages/database/src/Mappers/SelectModelMapper.php @@ -0,0 +1,80 @@ +getPrimaryField(); + + $parsed = arr($from) + ->groupBy(function (array $data) use ($idField) { + return $data[$idField]; + }) + ->map(fn (array $rows) => $this->normalizeFields($model, $rows)); + + return map($parsed->values()->toArray())->collection()->to($to); + } + + private function normalizeFields(ModelInspector $model, array $rows): array + { + $data = []; + + $mainTable = $model->getTableDefinition()->name; + + $hasManyRelations = []; + + foreach ($rows as $row) { + foreach ($row as $field => $value) { + $mainField = explode('.', $field)[0]; + + // Main fields + if ($mainField === $mainTable) { + $data[substr($field, strlen($mainTable) + 1)] = $value; + continue; + } + + // BelongsTo + if ($belongsTo = $model->getBelongsTo($mainField)) { + $data[$belongsTo->property->getName()][str_replace($mainField . '.', '', $field)] = $value; + } + + // HasOne + if ($hasOne = $model->getHasOne($mainField)) { + $data[$hasOne->property->getName()][str_replace($mainField . '.', '', $field)] = $value; + } + + // HasMany + if ($hasMany = $model->getHasMany($mainField)) { + $hasManyRelations[$mainField] ??= $hasMany; + + $hasManyId = $row[$hasMany->fieldName]; + + $data[$hasMany->property->getName()][$hasManyId][str_replace($mainField . '.', '', $field)] = $value; + } + } + } + + foreach ($hasManyRelations as $name => $hasMany) { + $data[$name] = array_values($data[$name]); + } + + return $data; + } +} \ No newline at end of file diff --git a/packages/database/src/Query.php b/packages/database/src/Query.php index a4cc26487..97d6a8e4d 100644 --- a/packages/database/src/Query.php +++ b/packages/database/src/Query.php @@ -36,7 +36,6 @@ public function execute(mixed ...$bindings): Id public function fetch(mixed ...$bindings): array { - lw($this->getSql()); return $this->getDatabase()->fetch($this->withBindings($bindings)); } diff --git a/packages/database/src/QueryStatements/FieldStatement.php b/packages/database/src/QueryStatements/FieldStatement.php index b28aec1a3..2572daeaf 100644 --- a/packages/database/src/QueryStatements/FieldStatement.php +++ b/packages/database/src/QueryStatements/FieldStatement.php @@ -16,19 +16,28 @@ public function __construct( public function compile(DatabaseDialect $dialect): string { + $parts = explode(' AS ', str_replace(' as ', ' AS ', $this->field)); + if (count($parts) === 1) { + $field = $parts[0]; + $alias = null; + } else { + $field = $parts[0]; + $alias = $parts[1]; + } - return arr(explode(' AS ', (string) $this->field)) - ->map(function (string $part) use ($dialect) { - return - arr(explode('.', $part)) - ->map(fn (string $part) => trim($part, '` ')) - ->map(fn (string $part) => match ($dialect) { - DatabaseDialect::SQLITE => $part, - default => sprintf('`%s`', $part), - }) - ->implode('.'); + $field = arr(explode('.', $field)) + ->map(fn (string $part) => trim($part, '` ')) + ->map(fn (string $part) => match ($dialect) { + DatabaseDialect::SQLITE => $part, + default => sprintf('`%s`', $part), }) - ->implode(' AS '); + ->implode('.'); + + if ($alias === null) { + return $field; + } + + return sprintf('%s AS `%s`', $field, trim($alias, '`')); } } diff --git a/packages/database/tests/QueryStatements/FieldStatementTest.php b/packages/database/tests/QueryStatements/FieldStatementTest.php index 1284e3ec5..8218e93f7 100644 --- a/packages/database/tests/QueryStatements/FieldStatementTest.php +++ b/packages/database/tests/QueryStatements/FieldStatementTest.php @@ -50,13 +50,18 @@ public function test_postgres(): void public function test_with_as(): void { $this->assertSame( - 'parent.through[].id AS parent.through[].id', - new FieldStatement('`parent.through[]`.`id` AS `parent.through[].id`')->compile(DatabaseDialect::SQLITE), + 'authors.name AS `authors.name`', + new FieldStatement('authors.name AS `authors.name`')->compile(DatabaseDialect::SQLITE), ); $this->assertSame( - '`parent.through[]`.`id` AS `parent.through[].id`', - new FieldStatement('`parent.through[]`.`id` AS `parent.through[].id`')->compile(DatabaseDialect::MYSQL), + 'authors.name AS `authors.name`', + new FieldStatement('authors.name AS authors.name')->compile(DatabaseDialect::SQLITE), + ); + + $this->assertSame( + '`authors`.`name` AS `authors.name`', + new FieldStatement('authors.name AS `authors.name`')->compile(DatabaseDialect::MYSQL), ); } } diff --git a/packages/reflection/src/HasAttributes.php b/packages/reflection/src/HasAttributes.php index fe7d84e6c..14ac25898 100644 --- a/packages/reflection/src/HasAttributes.php +++ b/packages/reflection/src/HasAttributes.php @@ -4,6 +4,7 @@ namespace Tempest\Reflection; +use ReflectionAttribute; use ReflectionAttribute as PHPReflectionAttribute; use ReflectionClass as PHPReflectionClass; use ReflectionMethod as PHPReflectionMethod; @@ -31,7 +32,7 @@ public function getAttribute(string $attributeClass, bool $recursive = false): ? { $attribute = $this->getReflection()->getAttributes($attributeClass, PHPReflectionAttribute::IS_INSTANCEOF)[0] ?? null; - $attributeInstance = $attribute?->newInstance(); + $attributeInstance = $this->instantiate($attribute); if ($attributeInstance || ! $recursive) { return $attributeInstance; @@ -62,8 +63,23 @@ public function getAttribute(string $attributeClass, bool $recursive = false): ? public function getAttributes(string $attributeClass): array { return array_map( - fn (PHPReflectionAttribute $attribute) => $attribute->newInstance(), + fn (PHPReflectionAttribute $attribute) => $this->instantiate($attribute), $this->getReflection()->getAttributes($attributeClass, PHPReflectionAttribute::IS_INSTANCEOF), ); } + + private function instantiate(?ReflectionAttribute $attribute): ?object + { + $object = $attribute?->newInstance(); + + if (! $object) { + return null; + } + + if ($object instanceof PropertyAttribute && $this instanceof PropertyReflector) { + $object->property = $this; + } + + return $object; + } } diff --git a/packages/reflection/src/PropertyAttribute.php b/packages/reflection/src/PropertyAttribute.php new file mode 100644 index 000000000..c009db18c --- /dev/null +++ b/packages/reflection/src/PropertyAttribute.php @@ -0,0 +1,11 @@ +getProperty('prop'); + $attribute = $property->getAttribute(AttributeImplementingPropertyAttribute::class); + + $this->assertSame('prop', $attribute->property->getName()); + } +} \ No newline at end of file diff --git a/packages/support/src/Str/ManipulatesString.php b/packages/support/src/Str/ManipulatesString.php index f18f82524..dbe7777e3 100644 --- a/packages/support/src/Str/ManipulatesString.php +++ b/packages/support/src/Str/ManipulatesString.php @@ -129,6 +129,14 @@ public function pluralizeLastWord(int|array|Countable $count = 2): self return $this->createOrModify(pluralize_last_word($this->value, $count)); } + /** + * Converts the last word to its English plural form. + */ + public function singularizeLastWord(): self + { + return $this->createOrModify(singularize_last_word($this->value)); + } + /** * Creates a pseudo-random alpha-numeric string of the given length. */ diff --git a/packages/support/src/Str/functions.php b/packages/support/src/Str/functions.php index 78408a900..691b465ce 100644 --- a/packages/support/src/Str/functions.php +++ b/packages/support/src/Str/functions.php @@ -199,6 +199,18 @@ function pluralize_last_word(Stringable|string $string, int|array|Countable $cou return implode('', $parts) . pluralize($lastWord, $count); } + /** + * Converts the last word of the given string to its English plural form. + */ + function singularize_last_word(Stringable|string $string): string + { + $string = (string) $string; + $parts = preg_split('/(.)(?=[A-Z])/u', $string, -1, PREG_SPLIT_DELIM_CAPTURE); + $lastWord = array_pop($parts); + + return implode('', $parts) . Language\singularize($lastWord); + } + /** * Ensures the given string starts with the specified `$prefix`. */ diff --git a/tests/Integration/Database/Mappers/DatabaseModelMapperTest.php b/tests/Integration/Database/Mappers/DatabaseModelMapperTest.php index 9eaf92105..ff0dc960f 100644 --- a/tests/Integration/Database/Mappers/DatabaseModelMapperTest.php +++ b/tests/Integration/Database/Mappers/DatabaseModelMapperTest.php @@ -2,27 +2,176 @@ namespace Integration\Database\Mappers; +use Tempest\Database\Mappers\SelectModelMapper; use Tests\Tempest\Fixtures\Migrations\CreateIsbnTable; use Tempest\Database\Migrations\CreateMigrationsTable; use Tests\Tempest\Fixtures\Migrations\CreateAuthorTable; use Tests\Tempest\Fixtures\Migrations\CreateBookTable; use Tests\Tempest\Fixtures\Migrations\CreateChapterTable; +use Tests\Tempest\Fixtures\Modules\Books\Models\Book; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; use function Tempest\Database\query; +use function Tempest\map; final class DatabaseModelMapperTest extends FrameworkIntegrationTestCase { public function test_map(): void { - $this->seed(); + $data = $this->data(); - $query = query('books') - ->select( - 'books.title', - 'authors.name', - ) - ->build() - ; + $books = map($data)->with(SelectModelMapper::class)->to(Book::class); + + $this->assertCount(4, $books); + $this->assertSame('LOTR 1', $books[0]->title); + $this->assertSame('LOTR 2', $books[1]->title); + $this->assertSame('LOTR 3', $books[2]->title); + $this->assertSame('Timeline Taxi', $books[3]->title); + + $book = $books[0]; + $this->assertSame('Tolkien', $book->author->name); + $this->assertCount(3, $book->chapters); + + $this->assertSame('LOTR 1.1', $book->chapters[0]->title); + $this->assertSame('LOTR 1.2', $book->chapters[1]->title); + $this->assertSame('LOTR 1.3', $book->chapters[2]->title); + + $this->assertSame('lotr-1', $book->isbn->value); + } + + private function data(): array + { + return [ + 0 => [ + 'books.id' => 1, + 'authors.id' => 2, + 'chapters.id' => 1, + 'isbns.id' => 1, + 'books.title' => 'LOTR 1', + 'authors.name' => 'Tolkien', + 'chapters.title' => 'LOTR 1.1', + 'isbns.value' => 'lotr-1', + ], + 1 => [ + 'books.id' => 1, + 'authors.id' => 2, + 'chapters.id' => 2, + 'isbns.id' => 1, + 'books.title' => 'LOTR 1', + 'authors.name' => 'Tolkien', + 'chapters.title' => 'LOTR 1.2', + 'isbns.value' => 'lotr-1', + ], + 2 => [ + 'books.id' => 1, + 'authors.id' => 2, + 'chapters.id' => 3, + 'isbns.id' => 1, + 'books.title' => 'LOTR 1', + 'authors.name' => 'Tolkien', + 'chapters.title' => 'LOTR 1.3', + 'isbns.value' => 'lotr-1', + ], + 3 => [ + 'books.id' => 2, + 'authors.id' => 2, + 'chapters.id' => 4, + 'isbns.id' => 2, + 'books.title' => 'LOTR 2', + 'authors.name' => 'Tolkien', + 'chapters.title' => 'LOTR 2.1', + 'isbns.value' => 'lotr-2', + ], + 4 => [ + 'books.id' => 2, + 'authors.id' => 2, + 'chapters.id' => 5, + 'isbns.id' => 2, + 'books.title' => 'LOTR 2', + 'authors.name' => 'Tolkien', + 'chapters.title' => 'LOTR 2.2', + 'isbns.value' => 'lotr-2', + ], + 5 => [ + 'books.id' => 2, + 'authors.id' => 2, + 'chapters.id' => 6, + 'isbns.id' => 2, + 'books.title' => 'LOTR 2', + 'authors.name' => 'Tolkien', + 'chapters.title' => 'LOTR 2.3', + 'isbns.value' => 'lotr-2', + ], + 6 => [ + 'books.id' => 3, + 'authors.id' => 2, + 'chapters.id' => 7, + 'isbns.id' => 3, + 'books.title' => 'LOTR 3', + 'authors.name' => 'Tolkien', + 'chapters.title' => 'LOTR 3.1', + 'isbns.value' => 'lotr-3', + ], + 7 => [ + 'books.id' => 3, + 'authors.id' => 2, + 'chapters.id' => 8, + 'isbns.id' => 3, + 'books.title' => 'LOTR 3', + 'authors.name' => 'Tolkien', + 'chapters.title' => 'LOTR 3.2', + 'isbns.value' => 'lotr-3', + ], + 8 => [ + 'books.id' => 3, + 'authors.id' => 2, + 'chapters.id' => 9, + 'isbns.id' => 3, + 'books.title' => 'LOTR 3', + 'authors.name' => 'Tolkien', + 'chapters.title' => 'LOTR 3.3', + 'isbns.value' => 'lotr-3', + ], + 9 => [ + 'books.id' => 4, + 'authors.id' => 1, + 'chapters.id' => 10, + 'isbns.id' => 4, + 'books.title' => 'Timeline Taxi', + 'authors.name' => 'Brent', + 'chapters.title' => 'Timeline Taxi Chapter 1', + 'isbns.value' => 'tt', + ], + 10 => [ + 'books.id' => 4, + 'authors.id' => 1, + 'chapters.id' => 11, + 'isbns.id' => 4, + 'books.title' => 'Timeline Taxi', + 'authors.name' => 'Brent', + 'chapters.title' => 'Timeline Taxi Chapter 2', + 'isbns.value' => 'tt', + ], + 11 => [ + 'books.id' => 4, + 'authors.id' => 1, + 'chapters.id' => 12, + 'isbns.id' => 4, + 'books.title' => 'Timeline Taxi', + 'authors.name' => 'Brent', + 'chapters.title' => 'Timeline Taxi Chapter 3', + 'isbns.value' => 'tt', + ], + 12 => [ + 'books.id' => 4, + 'authors.id' => 1, + 'chapters.id' => 13, + 'isbns.id' => 4, + 'books.title' => 'Timeline Taxi', + 'authors.name' => 'Brent', + 'chapters.title' => 'Timeline Taxi Chapter 4', + 'isbns.value' => 'tt', + ], + ]; } private function seed(): void diff --git a/tests/Integration/Support/StringTest.php b/tests/Integration/Support/StringTest.php index 8a6df5895..2208c6ec6 100644 --- a/tests/Integration/Support/StringTest.php +++ b/tests/Integration/Support/StringTest.php @@ -20,4 +20,12 @@ public function test_plural_studly(): void $this->assertTrue(str('VortexField')->pluralizeLastWord()->equals('VortexFields')); $this->assertTrue(str('MultipleWordsInOneString')->pluralizeLastWord()->equals('MultipleWordsInOneStrings')); } + + public function test_singularize(): void + { + $this->assertTrue(str('RealHumans')->singularizeLastWord()->equals('RealHuman')); + $this->assertTrue(str('Models')->singularizeLastWord()->equals('Model')); + $this->assertTrue(str('VortexFields')->singularizeLastWord()->equals('VortexField')); + $this->assertTrue(str('MultipleWordsInOneStrings')->singularizeLastWord()->equals('MultipleWordsInOneString')); + } } From eb98ddedfee792c3d875a528619916d19292ddbd Mon Sep 17 00:00:00 2001 From: brendt Date: Thu, 15 May 2025 07:59:35 +0200 Subject: [PATCH 05/28] wip --- packages/database/src/BelongsTo.php | 68 ++++++++++++++- .../database/src/Builder/ModelInspector.php | 70 ++++++++++++++- .../Builder/QueryBuilders/QueryBuilder.php | 2 +- .../QueryBuilders/SelectQueryBuilder.php | 63 +++++++------- .../Builder/Relations/BelongsToRelation.php | 4 +- .../src/Builder/Relations/Relation.php | 1 + packages/database/src/HasMany.php | 69 +++++++++++++-- packages/database/src/HasOne.php | 55 +++++++++++- .../src/Mappers/SelectModelMapper.php | 2 +- .../src/QueryStatements/FieldStatement.php | 24 +++++- .../src/QueryStatements/SelectStatement.php | 14 +-- packages/database/src/Relation.php | 14 +++ .../QueryStatements/FieldStatementTest.php | 13 +++ .../QueryStatements/SelectStatementTest.php | 2 +- .../Builder/SelectQueryBuilderTest.php | 86 +++++++++++++++++++ ...pperTest.php => SelectModelMapperTest.php} | 48 +---------- 16 files changed, 428 insertions(+), 107 deletions(-) create mode 100644 packages/database/src/Relation.php rename tests/Integration/Database/Mappers/{DatabaseModelMapperTest.php => SelectModelMapperTest.php} (77%) diff --git a/packages/database/src/BelongsTo.php b/packages/database/src/BelongsTo.php index f41b504c8..f5378e985 100644 --- a/packages/database/src/BelongsTo.php +++ b/packages/database/src/BelongsTo.php @@ -5,16 +5,76 @@ namespace Tempest\Database; use Attribute; -use Tempest\Reflection\PropertyAttribute; +use Tempest\Database\QueryStatements\FieldStatement; +use Tempest\Database\QueryStatements\JoinStatement; use Tempest\Reflection\PropertyReflector; +use Tempest\Support\Arr\ImmutableArray; +use function Tempest\Support\str; #[Attribute(Attribute::TARGET_PROPERTY)] -final class BelongsTo implements PropertyAttribute +final class BelongsTo implements Relation { public PropertyReflector $property; public function __construct( - public string $localPropertyName, - public string $inversePropertyName = 'id', + public ?string $relationJoin = null, + public ?string $ownerJoin = null, ) {} + + public function getOwnerFieldName(): string + { + if ($this->ownerJoin) { + return explode('.', $this->ownerJoin)[1]; + } + + $relationModel = model($this->property->getType()->asClass()); + + return str($relationModel->getTableName())->singularizeLastWord() . '_' . $relationModel->getPrimaryKey(); + } + + public function getSelectFields(): ImmutableArray + { + $relationModel = model($this->property->getType()->asClass()); + + return $relationModel + ->getSelectFields() + ->map(fn ($field) => new FieldStatement($relationModel->getTableName() . '.' . $field)->withAlias()); + } + + public function getJoinStatement(): JoinStatement + { + $relationModel = model($this->property->getType()->asClass()); + + // authors.id + $relationJoin = $this->relationJoin; + + if (! $relationJoin) { + $relationJoin = sprintf( + '%s.%s', + $relationModel->getTableName(), + $relationModel->getPrimaryKey(), + ); + } + + // books.author_id + $ownerJoin = $this->ownerJoin; + + if (! $ownerJoin) { + $ownerModel = model($this->property->getClass()); + + $ownerJoin = sprintf( + '%s.%s', + $ownerModel->getTableName(), + $this->getOwnerFieldName(), + ); + } + + // LEFT JOIN authors ON authors.id = books.author_id + return new JoinStatement(sprintf( + 'LEFT JOIN %s ON %s = %s', + $relationModel->getTableName(), + $relationJoin, + $ownerJoin, + )); + } } diff --git a/packages/database/src/Builder/ModelInspector.php b/packages/database/src/Builder/ModelInspector.php index a8730fda4..fe01217f3 100644 --- a/packages/database/src/Builder/ModelInspector.php +++ b/packages/database/src/Builder/ModelInspector.php @@ -5,15 +5,21 @@ use ReflectionException; use Tempest\Database\BelongsTo; use Tempest\Database\Config\DatabaseConfig; +use Tempest\Database\Eager; use Tempest\Database\HasMany; use Tempest\Database\HasOne; +use Tempest\Database\Relation; use Tempest\Database\Table; +use Tempest\Database\Virtual; use Tempest\Reflection\ClassReflector; +use Tempest\Reflection\PropertyReflector; +use Tempest\Support\Arr\ImmutableArray; use Tempest\Validation\Exceptions\ValidationException; use Tempest\Validation\SkipValidation; use Tempest\Validation\Validator; use function Tempest\get; +use function Tempest\Support\arr; use function Tempest\Support\str; final class ModelInspector @@ -57,6 +63,11 @@ public function getTableDefinition(): TableDefinition return new TableDefinition($specificName ?? $conventionalName); } + public function getTableName(): string + { + return $this->getTableDefinition()->name; + } + public function getPropertyValues(): array { if (! $this->isObjectModel()) { @@ -116,7 +127,7 @@ public function getBelongsTo(string $name): ?BelongsTo return null; } - $belongsTo = new BelongsTo($property->getName()); + $belongsTo = new BelongsTo(); $belongsTo->property = $property; return $belongsTo; @@ -167,12 +178,67 @@ public function getHasMany(string $name): ?HasMany return null; } - $hasMany = new HasMany(inversePropertyName: $property->getName()); + $hasMany = new HasMany(); $hasMany->property = $property; return $hasMany; } + public function getSelectFields(): ImmutableArray + { + if (! $this->isObjectModel()) { + return arr(); + } + + $selectFields = arr(); + + foreach ($this->modelClass->getPublicProperties() as $property) { + $relation = $this->getRelation($property->getName()); + + if ($relation instanceof HasMany || $relation instanceof HasOne) { + continue; + } + + if ($property->hasAttribute(Virtual::class)) { + continue; + } + + if ($relation instanceof BelongsTo) { + $selectFields[] = $relation->getOwnerFieldName(); + } else { + $selectFields[] = $property->getName(); + } + } + + return $selectFields; + } + + public function getRelation(string|PropertyReflector $name): ?Relation + { + $name = $name instanceof PropertyReflector ? $name->getName() : $name; + + return $this->getBelongsTo($name) + ?? $this->getHasOne($name) + ?? $this->getHasMany($name); + } + + public function getEagerRelations(): array + { + if (! $this->isObjectModel()) { + return []; + } + + $relations = []; + + foreach ($this->modelClass->getPublicProperties() as $property) { + if ($property->hasAttribute(Eager::class)) { + $relations[$property->getName()] = $this->getRelation($property); + } + } + + return array_filter($relations); + } + public function validate(mixed ...$data): void { if (! $this->isObjectModel()) { diff --git a/packages/database/src/Builder/QueryBuilders/QueryBuilder.php b/packages/database/src/Builder/QueryBuilders/QueryBuilder.php index 750888951..2f4df44b3 100644 --- a/packages/database/src/Builder/QueryBuilders/QueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/QueryBuilder.php @@ -17,7 +17,7 @@ public function select(string ...$columns): SelectQueryBuilder { return new SelectQueryBuilder( model: $this->model, - columns: $columns !== [] ? arr($columns) : null, + fields: $columns !== [] ? arr($columns) : null, ); } diff --git a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php index df03c19a1..fc491c1a4 100644 --- a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php @@ -10,7 +10,9 @@ use Tempest\Database\Builder\TableDefinition; use Tempest\Database\Id; use Tempest\Database\Mappers\QueryToModelMapper; +use Tempest\Database\Mappers\SelectModelMapper; use Tempest\Database\Query; +use Tempest\Database\QueryStatements\FieldStatement; use Tempest\Database\QueryStatements\JoinStatement; use Tempest\Database\QueryStatements\OrderByStatement; use Tempest\Database\QueryStatements\RawStatement; @@ -20,6 +22,7 @@ use Tempest\Support\Arr\ImmutableArray; use Tempest\Support\Conditions\HasConditions; +use function Tempest\Database\model; use function Tempest\map; use function Tempest\reflect; use function Tempest\Support\arr; @@ -44,14 +47,17 @@ final class SelectQueryBuilder implements BuildsQuery private array $bindings = []; - public function __construct(string|object $model, ?ImmutableArray $columns = null) + public function __construct(string|object $model, ?ImmutableArray $fields = null) { $this->modelDefinition = ModelDefinition::tryFrom($model); $this->modelClass = is_object($model) ? $model::class : $model; + $model = model($this->modelClass); $this->select = new SelectStatement( table: $this->resolveTable($model), - columns: $columns ?? $this->resolveColumns(), + fields: $fields ?? $model + ->getSelectFields() + ->map(fn (string $fieldName) => new FieldStatement("{$model->getTableName()}.{$fieldName}")->withAlias()), ); } @@ -66,9 +72,9 @@ public function first(mixed ...$bindings): mixed return $query->fetchFirst(); } - $result = map($query) + $result = map($query->fetch()) ->collection() - ->with(QueryToModelMapper::class) + ->with(SelectModelMapper::class) ->to($this->modelClass); if ($result === []) { @@ -95,7 +101,10 @@ public function all(mixed ...$bindings): array return $query->fetch(); } - return map($query)->collection()->to($this->modelClass); + return map($query->fetch()) + ->collection() + ->with(SelectModelMapper::class) + ->to($this->modelClass); } /** @@ -215,15 +224,16 @@ public function toSql(): string public function build(mixed ...$bindings): Query { - $resolvedRelations = $this->resolveRelations(); - foreach ($this->joins as $join) { $this->select->join[] = new JoinStatement($join); } - foreach ($resolvedRelations as $relation) { - $this->select->columns = $this->select->columns->append(...$relation->getFieldDefinitions()->map(fn (FieldDefinition $field) => (string) $field->withAlias())); - $this->select->join[] = new JoinStatement($relation->getStatement()); + foreach ($this->getIncludedRelations() as $relation) { + $this->select->fields = $this->select->fields->append( + ...$relation->getSelectFields() + ); + + $this->select->join[] = $relation->getJoinStatement(); } return new Query($this->select, [...$this->bindings, ...$bindings]); @@ -243,32 +253,27 @@ private function resolveTable(string|object $model): TableDefinition return $this->modelDefinition->getTableDefinition(); } - private function resolveColumns(): ImmutableArray + /** @return \Tempest\Database\Relation[] */ + private function getIncludedRelations(): array { - if ($this->modelDefinition === null) { - return arr(); - } + $definition = model($this->modelClass); - return $this->modelDefinition - ->getFieldDefinitions() - ->filter(fn (FieldDefinition $field) => ! reflect($this->modelClass, $field->name)->hasAttribute(Virtual::class)) - ->map(fn (FieldDefinition $field) => (string) $field->withAlias()); - } - - private function resolveRelations(): ImmutableArray - { - if ($this->modelDefinition === null) { - return arr(); + if (! $definition->isObjectModel()) { + return []; } - $relations = $this->modelDefinition->getEagerRelations(); + $relations = $definition->getEagerRelations(); + + foreach ($this->relations as $relation) { + $relation = $definition->getRelation($relation); - foreach ($this->relations as $relationName) { - foreach ($this->modelDefinition->getRelations($relationName) as $relation) { - $relations[$relation->getRelationName()] = $relation; + if (! $relation) { + continue; } + + $relations[$relation->property->getName()] = $relation; } - return arr($relations); + return $relations; } } diff --git a/packages/database/src/Builder/Relations/BelongsToRelation.php b/packages/database/src/Builder/Relations/BelongsToRelation.php index b8710cec2..efbf84fca 100644 --- a/packages/database/src/Builder/Relations/BelongsToRelation.php +++ b/packages/database/src/Builder/Relations/BelongsToRelation.php @@ -37,10 +37,10 @@ public static function fromAttribute(BelongsTo $belongsTo, PropertyReflector $pr $relationModelClass = $property->getType()->asClass(); $localTable = TableDefinition::for($property->getClass(), $alias); - $localField = new FieldDefinition($localTable, $belongsTo->localPropertyName); + $localField = new FieldDefinition($localTable, $belongsTo->ownerJoin); $joinTable = TableDefinition::for($property->getType()->asClass(), "{$alias}.{$property->getName()}"); - $joinField = new FieldDefinition($joinTable, $belongsTo->inversePropertyName); + $joinField = new FieldDefinition($joinTable, $belongsTo->relationJoin); return new self($relationModelClass, $localField, $joinField); } diff --git a/packages/database/src/Builder/Relations/Relation.php b/packages/database/src/Builder/Relations/Relation.php index 9419d93ff..893818ab0 100644 --- a/packages/database/src/Builder/Relations/Relation.php +++ b/packages/database/src/Builder/Relations/Relation.php @@ -6,6 +6,7 @@ use Tempest\Support\Arr\ImmutableArray; +// TODO: remove interface Relation { public function getRelationName(): string; diff --git a/packages/database/src/HasMany.php b/packages/database/src/HasMany.php index b8d1a91f2..aa2281e03 100644 --- a/packages/database/src/HasMany.php +++ b/packages/database/src/HasMany.php @@ -5,11 +5,14 @@ namespace Tempest\Database; use Attribute; -use Tempest\Reflection\PropertyAttribute; +use Tempest\Database\QueryStatements\FieldStatement; +use Tempest\Database\QueryStatements\JoinStatement; use Tempest\Reflection\PropertyReflector; +use Tempest\Support\Arr\ImmutableArray; +use function Tempest\Support\str; #[Attribute(Attribute::TARGET_PROPERTY)] -final class HasMany implements PropertyAttribute +final class HasMany implements Relation { public PropertyReflector $property; @@ -17,10 +20,64 @@ final class HasMany implements PropertyAttribute get => $this->property->getName() . '.' . $this->localPropertyName; } - /** @param null|class-string $inverseClassName */ public function __construct( - public string $inversePropertyName, - public ?string $inverseClassName = null, - public string $localPropertyName = 'id', + public ?string $relationJoin = null, + public ?string $ownerJoin = null, ) {} + + public function getSelectFields(): ImmutableArray + { + $relationModel = model($this->property->getIterableType()->asClass()); + + return $relationModel + ->getSelectFields() + ->map(fn ($field) => new FieldStatement($relationModel->getTableName() . '.' . $field)->withAlias()); + } + + public function idField(): string + { + $relationModel = model($this->property->getIterableType()->asClass()); + + return sprintf( + '%s.%s', + $relationModel->getTableName(), + $relationModel->getPrimaryKey(), + ); + } + + public function getJoinStatement(): JoinStatement + { + $relationModel = model($this->property->getIterableType()->asClass()); + $ownerModel = model($this->property->getClass()); + + // chapters.book_id + $relationJoin = $this->relationJoin; + + if (! $relationJoin) { + $relationJoin = sprintf( + '%s.%s', + $relationModel->getTableName(), + str($ownerModel->getTableName())->singularizeLastWord() . '_' . $ownerModel->getPrimaryKey(), + ); + } + + // books.id + $ownerJoin = $this->ownerJoin; + + if (! $ownerJoin) { + $ownerJoin = sprintf( + '%s.%s', + $ownerModel->getTableName(), + $ownerModel->getPrimaryKey(), + ); + } + + // LEFT JOIN chapters ON chapters.book_id = books.id + return new JoinStatement(sprintf( + 'LEFT JOIN %s ON %s = %s', + $relationModel->getTableName(), + $relationJoin, + $ownerJoin, + )); + } } diff --git a/packages/database/src/HasOne.php b/packages/database/src/HasOne.php index f9951adcb..9ea29f5f8 100644 --- a/packages/database/src/HasOne.php +++ b/packages/database/src/HasOne.php @@ -5,15 +5,64 @@ namespace Tempest\Database; use Attribute; -use Tempest\Reflection\PropertyAttribute; +use Tempest\Database\QueryStatements\FieldStatement; +use Tempest\Database\QueryStatements\JoinStatement; use Tempest\Reflection\PropertyReflector; +use Tempest\Support\Arr\ImmutableArray; +use function Tempest\Support\str; #[Attribute(Attribute::TARGET_PROPERTY)] -final class HasOne implements PropertyAttribute +final class HasOne implements Relation { public PropertyReflector $property; public function __construct( - public ?string $inversePropertyName = null, + public ?string $relationJoin = null, + public ?string $ownerJoin = null, ) {} + + public function getSelectFields(): ImmutableArray + { + $relationModel = model($this->property->getType()->asClass()); + + return $relationModel + ->getSelectFields() + ->map(fn ($field) => new FieldStatement($relationModel->getTableName() . '.' . $field)->withAlias()); + } + + public function getJoinStatement(): JoinStatement + { + $relationModel = model($this->property->getType()->asClass()); + $ownerModel = model($this->property->getClass()); + + // isbns.book_id + $relationJoin = $this->relationJoin; + + if (! $relationJoin) { + $relationJoin = sprintf( + '%s.%s', + $relationModel->getTableName(), + str($ownerModel->getTableName())->singularizeLastWord() . '_' . $ownerModel->getPrimaryKey(), + ); + } + + // books.id + $ownerJoin = $this->ownerJoin; + + if (! $ownerJoin) { + $ownerJoin = sprintf( + '%s.%s', + $ownerModel->getTableName(), + $ownerModel->getPrimaryKey(), + ); + } + + // LEFT JOIN isbn ON isbns.book_id = books.id + return new JoinStatement(sprintf( + 'LEFT JOIN %s ON %s = %s', + $relationModel->getTableName(), + $relationJoin, + $ownerJoin, + )); + } } diff --git a/packages/database/src/Mappers/SelectModelMapper.php b/packages/database/src/Mappers/SelectModelMapper.php index 9761839af..c358ddcbd 100644 --- a/packages/database/src/Mappers/SelectModelMapper.php +++ b/packages/database/src/Mappers/SelectModelMapper.php @@ -64,7 +64,7 @@ private function normalizeFields(ModelInspector $model, array $rows): array if ($hasMany = $model->getHasMany($mainField)) { $hasManyRelations[$mainField] ??= $hasMany; - $hasManyId = $row[$hasMany->fieldName]; + $hasManyId = $row[$hasMany->idField()]; $data[$hasMany->property->getName()][$hasManyId][str_replace($mainField . '.', '', $field)] = $value; } diff --git a/packages/database/src/QueryStatements/FieldStatement.php b/packages/database/src/QueryStatements/FieldStatement.php index 2572daeaf..6d0e43635 100644 --- a/packages/database/src/QueryStatements/FieldStatement.php +++ b/packages/database/src/QueryStatements/FieldStatement.php @@ -8,21 +8,30 @@ use function Tempest\Support\arr; -final readonly class FieldStatement implements QueryStatement +final class FieldStatement implements QueryStatement { + private bool $withAlias = false; + public function __construct( - private string|Stringable $field, + private readonly string|Stringable $field, ) {} public function compile(DatabaseDialect $dialect): string { $parts = explode(' AS ', str_replace(' as ', ' AS ', $this->field)); + $field = $parts[0]; + if (count($parts) === 1) { - $field = $parts[0]; $alias = null; + + if ($this->withAlias) { + $alias = sprintf( + '`%s`', + str_replace('`', '', $field), + ); + } } else { - $field = $parts[0]; $alias = $parts[1]; } @@ -40,4 +49,11 @@ public function compile(DatabaseDialect $dialect): string return sprintf('%s AS `%s`', $field, trim($alias, '`')); } + + public function withAlias(): self + { + $this->withAlias = true; + + return $this; + } } diff --git a/packages/database/src/QueryStatements/SelectStatement.php b/packages/database/src/QueryStatements/SelectStatement.php index 0450df9f8..6acd5f8b7 100644 --- a/packages/database/src/QueryStatements/SelectStatement.php +++ b/packages/database/src/QueryStatements/SelectStatement.php @@ -13,7 +13,7 @@ final class SelectStatement implements QueryStatement { public function __construct( public TableDefinition $table, - public ImmutableArray $columns = new ImmutableArray(), + public ImmutableArray $fields = new ImmutableArray(), public ImmutableArray $join = new ImmutableArray(), public ImmutableArray $where = new ImmutableArray(), public ImmutableArray $orderBy = new ImmutableArray(), @@ -26,15 +26,15 @@ public function __construct( public function compile(DatabaseDialect $dialect): string { - $columns = $this->columns->isEmpty() + $columns = $this->fields->isEmpty() ? '*' - : $this->columns - ->map(function (string|Stringable $column) use ($dialect) { - if ($column instanceof FieldDefinition) { - return (string) $column; + : $this->fields + ->map(function (string|Stringable|FieldStatement $field) use ($dialect) { + if (! $field instanceof FieldStatement) { + $field = new FieldStatement($field); } - return new FieldStatement($column)->compile($dialect); + return $field->compile($dialect); }) ->implode(', '); diff --git a/packages/database/src/Relation.php b/packages/database/src/Relation.php new file mode 100644 index 000000000..c1438017f --- /dev/null +++ b/packages/database/src/Relation.php @@ -0,0 +1,14 @@ +compile(DatabaseDialect::MYSQL), ); } + + public function test_with_alias(): void + { + $this->assertSame( + 'authors.name AS `authors.name`', + new FieldStatement('authors.name')->withAlias()->compile(DatabaseDialect::SQLITE), + ); + + $this->assertSame( + '`authors`.`name` AS `authors.name`', + new FieldStatement('`authors`.`name`')->withAlias()->compile(DatabaseDialect::MYSQL), + ); + } } diff --git a/packages/database/tests/QueryStatements/SelectStatementTest.php b/packages/database/tests/QueryStatements/SelectStatementTest.php index e2583d788..05005910e 100644 --- a/packages/database/tests/QueryStatements/SelectStatementTest.php +++ b/packages/database/tests/QueryStatements/SelectStatementTest.php @@ -23,7 +23,7 @@ public function test_select(): void $statement = new SelectStatement( table: $tableDefinition, - columns: arr(['`a`', 'b', 'c', new FieldDefinition($tableDefinition, 'd', 'd_alias')]), + fields: arr(['`a`', 'b', 'c', new FieldDefinition($tableDefinition, 'd', 'd_alias')]), join: arr(new JoinStatement('INNER JOIN foo ON bar.id = foo.id')), where: arr(new WhereStatement('`foo` = "bar"')), orderBy: arr(new OrderByStatement('`foo` DESC')), diff --git a/tests/Integration/Database/Builder/SelectQueryBuilderTest.php b/tests/Integration/Database/Builder/SelectQueryBuilderTest.php index 27ed41c30..d446d5f6b 100644 --- a/tests/Integration/Database/Builder/SelectQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/SelectQueryBuilderTest.php @@ -8,6 +8,8 @@ use Tempest\Database\Migrations\CreateMigrationsTable; use Tests\Tempest\Fixtures\Migrations\CreateAuthorTable; use Tests\Tempest\Fixtures\Migrations\CreateBookTable; +use Tests\Tempest\Fixtures\Migrations\CreateChapterTable; +use Tests\Tempest\Fixtures\Migrations\CreateIsbnTable; use Tests\Tempest\Fixtures\Modules\Books\Models\Author; use Tests\Tempest\Fixtures\Modules\Books\Models\AuthorType; use Tests\Tempest\Fixtures\Modules\Books\Models\Book; @@ -291,4 +293,88 @@ public function test_select_all_with_non_object_model(): void $authors, ); } + + public function test_select_includes_belongs_to(): void + { + $query = query(Book::class)->select(); + + $this->assertSame(<<build()->getSql()); + } + + public function test_with_belongs_to_relation(): void + { + $query = query(Book::class) + ->select() + ->with('author', 'chapters', 'isbn') + ->build(); + + $this->assertSame(<<getSql()); + } + + public function test_select_query_execute_with_relations(): void + { + $this->seed(); + + $query = query(Book::class) + ->select() + ->with('author', 'chapters', 'isbn'); + + ld($query->all()); + ld($query->build()->getSql()); + } + + private function seed(): void + { + $this->migrate( + CreateMigrationsTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + CreateChapterTable::class, + CreateIsbnTable::class, + ); + + query('authors')->insert( + ['name' => 'Brent'], + ['name' => 'Tolkien'], + )->execute(); + + query('books')->insert( + ['title' => 'LOTR 1', 'author_id' => 2], + ['title' => 'LOTR 2', 'author_id' => 2], + ['title' => 'LOTR 3', 'author_id' => 2], + ['title' => 'Timeline Taxi', 'author_id' => 1], + )->execute(); + + query('isbns')->insert( + ['value' => 'lotr-1', 'book_id' => 1], + ['value' => 'lotr-2', 'book_id' => 2], + ['value' => 'lotr-3', 'book_id' => 3], + ['value' => 'tt', 'book_id' => 4], + )->execute(); + + query('chapters')->insert( + ['title' => 'LOTR 1.1', 'book_id' => 1], + ['title' => 'LOTR 1.2', 'book_id' => 1], + ['title' => 'LOTR 1.3', 'book_id' => 1], + ['title' => 'LOTR 2.1', 'book_id' => 2], + ['title' => 'LOTR 2.2', 'book_id' => 2], + ['title' => 'LOTR 2.3', 'book_id' => 2], + ['title' => 'LOTR 3.1', 'book_id' => 3], + ['title' => 'LOTR 3.2', 'book_id' => 3], + ['title' => 'LOTR 3.3', 'book_id' => 3], + ['title' => 'Timeline Taxi Chapter 1', 'book_id' => 4], + ['title' => 'Timeline Taxi Chapter 2', 'book_id' => 4], + ['title' => 'Timeline Taxi Chapter 3', 'book_id' => 4], + ['title' => 'Timeline Taxi Chapter 4', 'book_id' => 4], + )->execute(); + } } diff --git a/tests/Integration/Database/Mappers/DatabaseModelMapperTest.php b/tests/Integration/Database/Mappers/SelectModelMapperTest.php similarity index 77% rename from tests/Integration/Database/Mappers/DatabaseModelMapperTest.php rename to tests/Integration/Database/Mappers/SelectModelMapperTest.php index ff0dc960f..3f6fa153f 100644 --- a/tests/Integration/Database/Mappers/DatabaseModelMapperTest.php +++ b/tests/Integration/Database/Mappers/SelectModelMapperTest.php @@ -13,7 +13,7 @@ use function Tempest\Database\query; use function Tempest\map; -final class DatabaseModelMapperTest extends FrameworkIntegrationTestCase +final class SelectModelMapperTest extends FrameworkIntegrationTestCase { public function test_map(): void { @@ -173,50 +173,4 @@ private function data(): array ], ]; } - - private function seed(): void - { - $this->migrate( - CreateMigrationsTable::class, - CreateAuthorTable::class, - CreateBookTable::class, - CreateChapterTable::class, - CreateIsbnTable::class, - ); - - query('authors')->insert( - ['name' => 'Brent'], - ['name' => 'Tolkien'], - )->execute(); - - query('books')->insert( - ['title' => 'LOTR 1', 'author_id' => 2], - ['title' => 'LOTR 2', 'author_id' => 2], - ['title' => 'LOTR 3', 'author_id' => 2], - ['title' => 'Timeline Taxi', 'author_id' => 1], - )->execute(); - - query('isbns')->insert( - ['value' => 'lotr-1', 'book_id' => 1], - ['value' => 'lotr-2', 'book_id' => 2], - ['value' => 'lotr-3', 'book_id' => 3], - ['value' => 'tt', 'book_id' => 4], - )->execute(); - - query('chapters')->insert( - ['title' => 'LOTR 1.1', 'book_id' => 1], - ['title' => 'LOTR 1.2', 'book_id' => 1], - ['title' => 'LOTR 1.3', 'book_id' => 1], - ['title' => 'LOTR 2.1', 'book_id' => 2], - ['title' => 'LOTR 2.2', 'book_id' => 2], - ['title' => 'LOTR 2.3', 'book_id' => 2], - ['title' => 'LOTR 3.1', 'book_id' => 3], - ['title' => 'LOTR 3.2', 'book_id' => 3], - ['title' => 'LOTR 3.3', 'book_id' => 3], - ['title' => 'Timeline Taxi Chapter 1', 'book_id' => 4], - ['title' => 'Timeline Taxi Chapter 2', 'book_id' => 4], - ['title' => 'Timeline Taxi Chapter 3', 'book_id' => 4], - ['title' => 'Timeline Taxi Chapter 4', 'book_id' => 4], - )->execute(); - } } \ No newline at end of file From d73176f314c21e6d90c292461503a4fa77e2a240 Mon Sep 17 00:00:00 2001 From: brendt Date: Thu, 15 May 2025 08:03:33 +0200 Subject: [PATCH 06/28] wip --- .../QueryBuilders/SelectQueryBuilder.php | 2 -- .../Builder/SelectQueryBuilderTest.php | 22 +++++++++++++++---- .../Mappers/SelectModelMapperTest.php | 6 ----- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php index fc491c1a4..07e8336be 100644 --- a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php @@ -73,7 +73,6 @@ public function first(mixed ...$bindings): mixed } $result = map($query->fetch()) - ->collection() ->with(SelectModelMapper::class) ->to($this->modelClass); @@ -102,7 +101,6 @@ public function all(mixed ...$bindings): array } return map($query->fetch()) - ->collection() ->with(SelectModelMapper::class) ->to($this->modelClass); } diff --git a/tests/Integration/Database/Builder/SelectQueryBuilderTest.php b/tests/Integration/Database/Builder/SelectQueryBuilderTest.php index d446d5f6b..fe4239ae7 100644 --- a/tests/Integration/Database/Builder/SelectQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/SelectQueryBuilderTest.php @@ -324,12 +324,26 @@ public function test_select_query_execute_with_relations(): void { $this->seed(); - $query = query(Book::class) + $books = query(Book::class) ->select() - ->with('author', 'chapters', 'isbn'); + ->with('author', 'chapters', 'isbn') + ->all(); + + $this->assertCount(4, $books); + $this->assertSame('LOTR 1', $books[0]->title); + $this->assertSame('LOTR 2', $books[1]->title); + $this->assertSame('LOTR 3', $books[2]->title); + $this->assertSame('Timeline Taxi', $books[3]->title); + + $book = $books[0]; + $this->assertSame('Tolkien', $book->author->name); + $this->assertCount(3, $book->chapters); + + $this->assertSame('LOTR 1.1', $book->chapters[0]->title); + $this->assertSame('LOTR 1.2', $book->chapters[1]->title); + $this->assertSame('LOTR 1.3', $book->chapters[2]->title); - ld($query->all()); - ld($query->build()->getSql()); + $this->assertSame('lotr-1', $book->isbn->value); } private function seed(): void diff --git a/tests/Integration/Database/Mappers/SelectModelMapperTest.php b/tests/Integration/Database/Mappers/SelectModelMapperTest.php index 3f6fa153f..4bcd5d176 100644 --- a/tests/Integration/Database/Mappers/SelectModelMapperTest.php +++ b/tests/Integration/Database/Mappers/SelectModelMapperTest.php @@ -3,14 +3,8 @@ namespace Integration\Database\Mappers; use Tempest\Database\Mappers\SelectModelMapper; -use Tests\Tempest\Fixtures\Migrations\CreateIsbnTable; -use Tempest\Database\Migrations\CreateMigrationsTable; -use Tests\Tempest\Fixtures\Migrations\CreateAuthorTable; -use Tests\Tempest\Fixtures\Migrations\CreateBookTable; -use Tests\Tempest\Fixtures\Migrations\CreateChapterTable; use Tests\Tempest\Fixtures\Modules\Books\Models\Book; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; -use function Tempest\Database\query; use function Tempest\map; final class SelectModelMapperTest extends FrameworkIntegrationTestCase From 15411d0740d3f910c589c23886f69d0f918318ef Mon Sep 17 00:00:00 2001 From: brendt Date: Thu, 15 May 2025 08:26:47 +0200 Subject: [PATCH 07/28] wip --- packages/database/src/BelongsTo.php | 67 +++++++++++----- .../database/src/Builder/ModelDefinition.php | 33 -------- .../src/Exceptions/InvalidRelation.php | 1 + tests/Integration/Database/BelongsToTest.php | 77 +++++++++++++++++++ .../Relations/BelongsToRelationTest.php | 59 -------------- .../Fixtures/BelongsToOwnerModel.php | 29 +++++++ .../Fixtures/BelongsToParentModel.php | 23 ------ ...edModel.php => BelongsToRelationModel.php} | 10 +-- .../Relations/HasManyRelationTest.php | 10 +-- 9 files changed, 163 insertions(+), 146 deletions(-) create mode 100644 tests/Integration/Database/BelongsToTest.php delete mode 100644 tests/Integration/Database/Relations/BelongsToRelationTest.php create mode 100644 tests/Integration/Database/Relations/Fixtures/BelongsToOwnerModel.php delete mode 100644 tests/Integration/Database/Relations/Fixtures/BelongsToParentModel.php rename tests/Integration/Database/Relations/Fixtures/{BelongsToRelatedModel.php => BelongsToRelationModel.php} (75%) diff --git a/packages/database/src/BelongsTo.php b/packages/database/src/BelongsTo.php index f5378e985..fb6a412f0 100644 --- a/packages/database/src/BelongsTo.php +++ b/packages/database/src/BelongsTo.php @@ -5,6 +5,7 @@ namespace Tempest\Database; use Attribute; +use Tempest\Database\Builder\ModelInspector; use Tempest\Database\QueryStatements\FieldStatement; use Tempest\Database\QueryStatements\JoinStatement; use Tempest\Reflection\PropertyReflector; @@ -17,8 +18,8 @@ final class BelongsTo implements Relation public PropertyReflector $property; public function __construct( - public ?string $relationJoin = null, - public ?string $ownerJoin = null, + private readonly ?string $relationJoin = null, + private readonly ?string $ownerJoin = null, ) {} public function getOwnerFieldName(): string @@ -44,37 +45,61 @@ public function getSelectFields(): ImmutableArray public function getJoinStatement(): JoinStatement { $relationModel = model($this->property->getType()->asClass()); + $ownerModel = model($this->property->getClass()); - // authors.id + $relationJoin = $this->getRelationJoin($relationModel); + $ownerJoin = $this->getOwnerJoin($ownerModel); + + // LEFT JOIN authors ON authors.id = books.author_id + return new JoinStatement(sprintf( + 'LEFT JOIN %s ON %s = %s', + $relationModel->getTableName(), + $relationJoin, + $ownerJoin, + )); + } + + private function getRelationJoin(ModelInspector $relationModel): string + { $relationJoin = $this->relationJoin; - if (! $relationJoin) { - $relationJoin = sprintf( - '%s.%s', + if ($relationJoin && ! strpos($relationJoin, '.')) { + $relationJoin = sprintf('%s.%s', $relationModel->getTableName(), - $relationModel->getPrimaryKey(), + $relationJoin, ); } - // books.author_id - $ownerJoin = $this->ownerJoin; + if ($relationJoin) { + return $relationJoin; + } - if (! $ownerJoin) { - $ownerModel = model($this->property->getClass()); + return sprintf( + '%s.%s', + $relationModel->getTableName(), + $relationModel->getPrimaryKey(), + ); + } + + private function getOwnerJoin(ModelInspector $ownerModel): string + { + $ownerJoin = $this->ownerJoin; - $ownerJoin = sprintf( - '%s.%s', + if ($ownerJoin && ! strpos($ownerJoin, '.')) { + $ownerJoin = sprintf('%s.%s', $ownerModel->getTableName(), - $this->getOwnerFieldName(), + $ownerJoin, ); } - // LEFT JOIN authors ON authors.id = books.author_id - return new JoinStatement(sprintf( - 'LEFT JOIN %s ON %s = %s', - $relationModel->getTableName(), - $relationJoin, - $ownerJoin, - )); + if ($ownerJoin) { + return $ownerJoin; + } + + return sprintf( + '%s.%s', + $ownerModel->getTableName(), + $this->getOwnerFieldName(), + ); } } diff --git a/packages/database/src/Builder/ModelDefinition.php b/packages/database/src/Builder/ModelDefinition.php index 9d0d5447d..b25b919e9 100644 --- a/packages/database/src/Builder/ModelDefinition.php +++ b/packages/database/src/Builder/ModelDefinition.php @@ -82,39 +82,6 @@ public function getRelations(string $relationName): array return $relations; } - /** @return \Tempest\Database\Builder\Relations\Relation[] */ - public function getEagerRelations(): array - { - $relations = []; - - foreach ($this->buildEagerRelationNames($this->modelClass) as $relationName) { - foreach ($this->getRelations($relationName) as $relation) { - $relations[$relation->getRelationName()] = $relation; - } - } - - return $relations; - } - - private function buildEagerRelationNames(ClassReflector $class): array - { - $relations = []; - - foreach ($class->getPublicProperties() as $property) { - if (! $property->hasAttribute(Eager::class)) { - continue; - } - - $relations[] = $property->getName(); - - foreach ($this->buildEagerRelationNames($property->getType()->asClass()) as $childRelation) { - $relations[] = "{$property->getName()}.{$childRelation}"; - } - } - - return $relations; - } - public function getTableDefinition(): TableDefinition { $specificName = $this->modelClass diff --git a/packages/database/src/Exceptions/InvalidRelation.php b/packages/database/src/Exceptions/InvalidRelation.php index 02854e78c..0adfc3856 100644 --- a/packages/database/src/Exceptions/InvalidRelation.php +++ b/packages/database/src/Exceptions/InvalidRelation.php @@ -6,6 +6,7 @@ use Exception; +// TODO remove final class InvalidRelation extends Exception { public static function inversePropertyNotFound(string $modelClass, string $modelProperty, string $relatedClass): self diff --git a/tests/Integration/Database/BelongsToTest.php b/tests/Integration/Database/BelongsToTest.php new file mode 100644 index 000000000..d87e66b16 --- /dev/null +++ b/tests/Integration/Database/BelongsToTest.php @@ -0,0 +1,77 @@ +getRelation('relatedModel'); + + $this->assertInstanceOf(BelongsTo::class, $relation); + + $this->assertEquals( + 'LEFT JOIN relation ON relation.id = owner.relation_id', + $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE), + ); + } + + public function test_belongs_to_with_relation_join_field(): void + { + $model = model(BelongsToOwnerModel::class); + $relation = $model->getRelation('relationJoinField'); + + $this->assertInstanceOf(BelongsTo::class, $relation); + + $this->assertEquals( + 'LEFT JOIN relation ON relation.overwritten_id = owner.relation_id', + $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE), + ); + } + + public function test_belongs_to_with_relation_join_field_and_table(): void + { + $model = model(BelongsToOwnerModel::class); + $relation = $model->getRelation('relationJoinFieldAndTable'); + + $this->assertInstanceOf(BelongsTo::class, $relation); + + $this->assertEquals( + 'LEFT JOIN relation ON overwritten.overwritten_id = owner.relation_id', + $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE), + ); + } + + public function test_belongs_to_with_owner_join_field(): void + { + $model = model(BelongsToOwnerModel::class); + $relation = $model->getRelation('ownerJoinField'); + + $this->assertInstanceOf(BelongsTo::class, $relation); + + $this->assertEquals( + 'LEFT JOIN relation ON relation.id = owner.overwritten_id', + $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE), + ); + } + + public function test_belongs_to_with_owner_join_field_and_table(): void + { + $model = model(BelongsToOwnerModel::class); + $relation = $model->getRelation('ownerJoinFieldAndTable'); + + $this->assertInstanceOf(BelongsTo::class, $relation); + + $this->assertEquals( + 'LEFT JOIN relation ON relation.id = overwritten.overwritten_id', + $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE), + ); + } +} \ No newline at end of file diff --git a/tests/Integration/Database/Relations/BelongsToRelationTest.php b/tests/Integration/Database/Relations/BelongsToRelationTest.php deleted file mode 100644 index c22237830..000000000 --- a/tests/Integration/Database/Relations/BelongsToRelationTest.php +++ /dev/null @@ -1,59 +0,0 @@ -getRelations('relatedModel'); - - $this->assertCount(1, $inferredRelation); - $this->assertSame('belongs_to_parent_model.relatedModel', $inferredRelation[0]->getRelationName()); - $this->assertEquals( - 'LEFT JOIN `belongs_to_related` AS `belongs_to_parent_model.relatedModel`' . - ' ON `belongs_to_parent_model`.`relatedModel_id` = `belongs_to_parent_model.relatedModel`.`id`', - $inferredRelation[0]->getStatement(), - ); - } - - public function test_attribute_with_default_belongs_to_relation(): void - { - $definition = new ModelDefinition(BelongsToParentModel::class); - $namedRelation = $definition->getRelations('otherRelatedModel'); - - $this->assertCount(1, $namedRelation); - - $this->assertSame('belongs_to_parent_model.otherRelatedModel', $namedRelation[0]->getRelationName()); - $this->assertEquals( - 'LEFT JOIN `belongs_to_related` AS `belongs_to_parent_model.otherRelatedModel`' . - ' ON `belongs_to_parent_model`.`other_id` = `belongs_to_parent_model.otherRelatedModel`.`id`', - $namedRelation[0]->getStatement(), - ); - } - - public function test_attribute_belongs_to_relation(): void - { - $definition = new ModelDefinition(BelongsToParentModel::class); - $doublyNamedRelation = $definition->getRelations('stillOtherRelatedModel'); - - $this->assertCount(1, $doublyNamedRelation); - - $this->assertSame('belongs_to_parent_model.stillOtherRelatedModel', $doublyNamedRelation[0]->getRelationName()); - $this->assertEquals( - 'LEFT JOIN `belongs_to_related` AS `belongs_to_parent_model.stillOtherRelatedModel`' . - ' ON `belongs_to_parent_model`.`other_id` = `belongs_to_parent_model.stillOtherRelatedModel`.`other_id`', - $doublyNamedRelation[0]->getStatement(), - ); - } -} diff --git a/tests/Integration/Database/Relations/Fixtures/BelongsToOwnerModel.php b/tests/Integration/Database/Relations/Fixtures/BelongsToOwnerModel.php new file mode 100644 index 000000000..492ada80c --- /dev/null +++ b/tests/Integration/Database/Relations/Fixtures/BelongsToOwnerModel.php @@ -0,0 +1,29 @@ +expectException(InvalidRelation::class); $definition->getRelations('invalid'); @@ -24,7 +24,7 @@ public function test_cannot_find_inverse(): void public function test_inferred_has_many_relation(): void { - $definition = new ModelDefinition(BelongsToRelatedModel::class); + $definition = new ModelDefinition(BelongsToRelationModel::class); $inferredRelation = $definition->getRelations('inferred'); $this->assertCount(1, $inferredRelation); @@ -37,7 +37,7 @@ public function test_inferred_has_many_relation(): void public function test_attribute_with_defaults_has_many_relation(): void { - $definition = new ModelDefinition(BelongsToRelatedModel::class); + $definition = new ModelDefinition(BelongsToRelationModel::class); $relation = $definition->getRelations('attribute'); $this->assertCount(1, $relation); @@ -50,7 +50,7 @@ public function test_attribute_with_defaults_has_many_relation(): void public function test_fully_filled_attribute_has_many_relation(): void { - $definition = new ModelDefinition(BelongsToRelatedModel::class); + $definition = new ModelDefinition(BelongsToRelationModel::class); $relation = $definition->getRelations('full'); $this->assertCount(1, $relation); From 317d40ab6424355255186416fd60f9b9ae13bc5d Mon Sep 17 00:00:00 2001 From: brendt Date: Thu, 15 May 2025 08:52:35 +0200 Subject: [PATCH 08/28] wip --- packages/database/src/HasMany.php | 73 ++++++++++++------- packages/database/src/HasOne.php | 69 ++++++++++++------ tests/Integration/Database/BelongsToTest.php | 14 ++-- .../Database/Fixtures/HasOneRelationModel.php | 27 +++++++ .../Database/Fixtures/OwnerModel.php | 26 +++++++ .../Database/Fixtures/RelationModel.php | 31 ++++++++ tests/Integration/Database/HasManyTest.php | 72 ++++++++++++++++++ tests/Integration/Database/HasOneTest.php | 72 ++++++++++++++++++ .../Fixtures/BelongsToOwnerModel.php | 29 -------- .../Fixtures/BelongsToRelationModel.php | 28 ------- .../Fixtures/HasOneInvalidRelatedModel.php | 18 ----- .../Relations/Fixtures/HasOneParentModel.php | 30 -------- .../Relations/Fixtures/HasOneRelatedModel.php | 19 ----- .../Relations/HasManyRelationTest.php | 63 ---------------- .../Database/Relations/HasOneRelationTest.php | 40 ---------- 15 files changed, 331 insertions(+), 280 deletions(-) create mode 100644 tests/Integration/Database/Fixtures/HasOneRelationModel.php create mode 100644 tests/Integration/Database/Fixtures/OwnerModel.php create mode 100644 tests/Integration/Database/Fixtures/RelationModel.php create mode 100644 tests/Integration/Database/HasManyTest.php create mode 100644 tests/Integration/Database/HasOneTest.php delete mode 100644 tests/Integration/Database/Relations/Fixtures/BelongsToOwnerModel.php delete mode 100644 tests/Integration/Database/Relations/Fixtures/BelongsToRelationModel.php delete mode 100644 tests/Integration/Database/Relations/Fixtures/HasOneInvalidRelatedModel.php delete mode 100644 tests/Integration/Database/Relations/Fixtures/HasOneParentModel.php delete mode 100644 tests/Integration/Database/Relations/Fixtures/HasOneRelatedModel.php delete mode 100644 tests/Integration/Database/Relations/HasManyRelationTest.php delete mode 100644 tests/Integration/Database/Relations/HasOneRelationTest.php diff --git a/packages/database/src/HasMany.php b/packages/database/src/HasMany.php index aa2281e03..f9319c435 100644 --- a/packages/database/src/HasMany.php +++ b/packages/database/src/HasMany.php @@ -5,6 +5,7 @@ namespace Tempest\Database; use Attribute; +use Tempest\Database\Builder\ModelInspector; use Tempest\Database\QueryStatements\FieldStatement; use Tempest\Database\QueryStatements\JoinStatement; use Tempest\Reflection\PropertyReflector; @@ -16,13 +17,9 @@ final class HasMany implements Relation { public PropertyReflector $property; - public string $fieldName { - get => $this->property->getName() . '.' . $this->localPropertyName; - } - public function __construct( - public ?string $relationJoin = null, public ?string $ownerJoin = null, + public ?string $relationJoin = null, ) {} public function getSelectFields(): ImmutableArray @@ -47,37 +44,63 @@ public function idField(): string public function getJoinStatement(): JoinStatement { - $relationModel = model($this->property->getIterableType()->asClass()); - $ownerModel = model($this->property->getClass()); + $ownerModel = model($this->property->getIterableType()->asClass()); + $relationModel = model($this->property->getClass()); - // chapters.book_id - $relationJoin = $this->relationJoin; + $ownerJoin = $this->getOwnerJoin($ownerModel, $relationModel); + $relationJoin = $this->getRelationJoin($relationModel); - if (! $relationJoin) { - $relationJoin = sprintf( - '%s.%s', - $relationModel->getTableName(), - str($ownerModel->getTableName())->singularizeLastWord() . '_' . $ownerModel->getPrimaryKey(), - ); - } + return new JoinStatement(sprintf( + 'LEFT JOIN %s ON %s = %s', + $ownerModel->getTableName(), + $ownerJoin, + $relationJoin, + )); + } - // books.id + private function getOwnerJoin(ModelInspector $ownerModel, ModelInspector $relationModel): string + { $ownerJoin = $this->ownerJoin; - if (! $ownerJoin) { + if ($ownerJoin && ! strpos($ownerJoin, '.')) { $ownerJoin = sprintf( '%s.%s', $ownerModel->getTableName(), - $ownerModel->getPrimaryKey(), + $ownerJoin, ); } - // LEFT JOIN chapters ON chapters.book_id = books.id - return new JoinStatement(sprintf( - 'LEFT JOIN %s ON %s = %s', + if ($ownerJoin) { + return $ownerJoin; + } + + return sprintf( + '%s.%s', + $ownerModel->getTableName(), + str($relationModel->getTableName())->singularizeLastWord() . '_' . $relationModel->getPrimaryKey(), + ); + } + + private function getRelationJoin(ModelInspector $relationModel): string + { + $relationJoin = $this->relationJoin; + + if ($relationJoin && ! strpos($relationJoin, '.')) { + $relationJoin = sprintf( + '%s.%s', + $relationModel->getTableName(), + $relationJoin, + ); + } + + if ($relationJoin) { + return $relationJoin; + } + + return sprintf( + '%s.%s', $relationModel->getTableName(), - $relationJoin, - $ownerJoin, - )); + $relationModel->getPrimaryKey(), + ); } } diff --git a/packages/database/src/HasOne.php b/packages/database/src/HasOne.php index 9ea29f5f8..c5fb3cd17 100644 --- a/packages/database/src/HasOne.php +++ b/packages/database/src/HasOne.php @@ -5,6 +5,7 @@ namespace Tempest\Database; use Attribute; +use Tempest\Database\Builder\ModelInspector; use Tempest\Database\QueryStatements\FieldStatement; use Tempest\Database\QueryStatements\JoinStatement; use Tempest\Reflection\PropertyReflector; @@ -17,8 +18,8 @@ final class HasOne implements Relation public PropertyReflector $property; public function __construct( - public ?string $relationJoin = null, public ?string $ownerJoin = null, + public ?string $relationJoin = null, ) {} public function getSelectFields(): ImmutableArray @@ -32,37 +33,63 @@ public function getSelectFields(): ImmutableArray public function getJoinStatement(): JoinStatement { - $relationModel = model($this->property->getType()->asClass()); - $ownerModel = model($this->property->getClass()); + $ownerModel = model($this->property->getType()->asClass()); + $relationModel = model($this->property->getClass()); - // isbns.book_id - $relationJoin = $this->relationJoin; + $ownerJoin = $this->getOwnerJoin($ownerModel, $relationModel); + $relationJoin = $this->getRelationJoin($relationModel); - if (! $relationJoin) { - $relationJoin = sprintf( - '%s.%s', - $relationModel->getTableName(), - str($ownerModel->getTableName())->singularizeLastWord() . '_' . $ownerModel->getPrimaryKey(), - ); - } + return new JoinStatement(sprintf( + 'LEFT JOIN %s ON %s = %s', + $ownerModel->getTableName(), + $ownerJoin, + $relationJoin, + )); + } - // books.id + private function getOwnerJoin(ModelInspector $ownerModel, ModelInspector $relationModel): string + { $ownerJoin = $this->ownerJoin; - if (! $ownerJoin) { + if ($ownerJoin && ! strpos($ownerJoin, '.')) { $ownerJoin = sprintf( '%s.%s', $ownerModel->getTableName(), - $ownerModel->getPrimaryKey(), + $ownerJoin, ); } - // LEFT JOIN isbn ON isbns.book_id = books.id - return new JoinStatement(sprintf( - 'LEFT JOIN %s ON %s = %s', + if ($ownerJoin) { + return $ownerJoin; + } + + return sprintf( + '%s.%s', + $ownerModel->getTableName(), + str($relationModel->getTableName())->singularizeLastWord() . '_' . $relationModel->getPrimaryKey(), + ); + } + + private function getRelationJoin(ModelInspector $relationModel): string + { + $relationJoin = $this->relationJoin; + + if ($relationJoin && ! strpos($relationJoin, '.')) { + $relationJoin = sprintf( + '%s.%s', + $relationModel->getTableName(), + $relationJoin, + ); + } + + if ($relationJoin) { + return $relationJoin; + } + + return sprintf( + '%s.%s', $relationModel->getTableName(), - $relationJoin, - $ownerJoin, - )); + $relationModel->getPrimaryKey(), + ); } } diff --git a/tests/Integration/Database/BelongsToTest.php b/tests/Integration/Database/BelongsToTest.php index d87e66b16..5be4c0f5d 100644 --- a/tests/Integration/Database/BelongsToTest.php +++ b/tests/Integration/Database/BelongsToTest.php @@ -4,7 +4,7 @@ use Tempest\Database\BelongsTo; use Tempest\Database\Config\DatabaseDialect; -use Tests\Tempest\Integration\Database\Relations\Fixtures\BelongsToOwnerModel; +use Tests\Tempest\Integration\Database\Fixtures\OwnerModel; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; use function Tempest\Database\model; @@ -12,8 +12,8 @@ final class BelongsToTest extends FrameworkIntegrationTestCase { public function test_belongs_to(): void { - $model = model(BelongsToOwnerModel::class); - $relation = $model->getRelation('relatedModel'); + $model = model(OwnerModel::class); + $relation = $model->getRelation('relation'); $this->assertInstanceOf(BelongsTo::class, $relation); @@ -25,7 +25,7 @@ public function test_belongs_to(): void public function test_belongs_to_with_relation_join_field(): void { - $model = model(BelongsToOwnerModel::class); + $model = model(OwnerModel::class); $relation = $model->getRelation('relationJoinField'); $this->assertInstanceOf(BelongsTo::class, $relation); @@ -38,7 +38,7 @@ public function test_belongs_to_with_relation_join_field(): void public function test_belongs_to_with_relation_join_field_and_table(): void { - $model = model(BelongsToOwnerModel::class); + $model = model(OwnerModel::class); $relation = $model->getRelation('relationJoinFieldAndTable'); $this->assertInstanceOf(BelongsTo::class, $relation); @@ -51,7 +51,7 @@ public function test_belongs_to_with_relation_join_field_and_table(): void public function test_belongs_to_with_owner_join_field(): void { - $model = model(BelongsToOwnerModel::class); + $model = model(OwnerModel::class); $relation = $model->getRelation('ownerJoinField'); $this->assertInstanceOf(BelongsTo::class, $relation); @@ -64,7 +64,7 @@ public function test_belongs_to_with_owner_join_field(): void public function test_belongs_to_with_owner_join_field_and_table(): void { - $model = model(BelongsToOwnerModel::class); + $model = model(OwnerModel::class); $relation = $model->getRelation('ownerJoinFieldAndTable'); $this->assertInstanceOf(BelongsTo::class, $relation); diff --git a/tests/Integration/Database/Fixtures/HasOneRelationModel.php b/tests/Integration/Database/Fixtures/HasOneRelationModel.php new file mode 100644 index 000000000..72602a0af --- /dev/null +++ b/tests/Integration/Database/Fixtures/HasOneRelationModel.php @@ -0,0 +1,27 @@ +getRelation('owners'); + + $this->assertInstanceOf(HasMany::class, $relation); + $this->assertSame( + 'LEFT JOIN owner ON owner.relation_id = relation.id', + $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE) + ); + } + + public function test_has_many_with_overwritten_owner_join_field(): void + { + $model = model(RelationModel::class); + $relation = $model->getRelation('ownerJoinField'); + + $this->assertInstanceOf(HasMany::class, $relation); + $this->assertSame( + 'LEFT JOIN owner ON owner.overwritten_id = relation.id', + $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE) + ); + } + + public function test_has_many_with_overwritten_owner_join_field_and_table(): void + { + $model = model(RelationModel::class); + $relation = $model->getRelation('ownerJoinFieldAndTable'); + + $this->assertInstanceOf(HasMany::class, $relation); + $this->assertSame( + 'LEFT JOIN owner ON overwritten.overwritten_id = relation.id', + $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE) + ); + } + + public function test_has_many_with_overwritten_relation_join_field(): void + { + $model = model(RelationModel::class); + $relation = $model->getRelation('relationJoinField'); + + $this->assertInstanceOf(HasMany::class, $relation); + $this->assertSame( + 'LEFT JOIN owner ON owner.relation_id = relation.overwritten_id', + $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE) + ); + } + + public function test_has_many_with_overwritten_relation_join_field_and_table(): void + { + $model = model(RelationModel::class); + $relation = $model->getRelation('relationJoinFieldAndTable'); + + $this->assertInstanceOf(HasMany::class, $relation); + $this->assertSame( + 'LEFT JOIN owner ON owner.relation_id = overwritten.overwritten_id', + $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE) + ); + } +} \ No newline at end of file diff --git a/tests/Integration/Database/HasOneTest.php b/tests/Integration/Database/HasOneTest.php new file mode 100644 index 000000000..d29de4348 --- /dev/null +++ b/tests/Integration/Database/HasOneTest.php @@ -0,0 +1,72 @@ +getRelation('owner'); + + $this->assertInstanceOf(HasOne::class, $relation); + $this->assertSame( + 'LEFT JOIN owner ON owner.relation_id = relation.id', + $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE) + ); + } + + public function test_has_one_with_overwritten_owner_join_field(): void + { + $model = model(HasOneRelationModel::class); + $relation = $model->getRelation('ownerJoinField'); + + $this->assertInstanceOf(HasOne::class, $relation); + $this->assertSame( + 'LEFT JOIN owner ON owner.overwritten_id = relation.id', + $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE) + ); + } + + public function test_has_one_with_overwritten_owner_join_field_and_table(): void + { + $model = model(HasOneRelationModel::class); + $relation = $model->getRelation('ownerJoinFieldAndTable'); + + $this->assertInstanceOf(HasOne::class, $relation); + $this->assertSame( + 'LEFT JOIN owner ON overwritten.overwritten_id = relation.id', + $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE) + ); + } + + public function test_has_one_with_overwritten_relation_join_field(): void + { + $model = model(HasOneRelationModel::class); + $relation = $model->getRelation('relationJoinField'); + + $this->assertInstanceOf(HasOne::class, $relation); + $this->assertSame( + 'LEFT JOIN owner ON owner.relation_id = relation.overwritten_id', + $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE) + ); + } + + public function test_has_one_with_overwritten_relation_join_field_and_table(): void + { + $model = model(HasOneRelationModel::class); + $relation = $model->getRelation('relationJoinFieldAndTable'); + + $this->assertInstanceOf(HasOne::class, $relation); + $this->assertSame( + 'LEFT JOIN owner ON owner.relation_id = overwritten.overwritten_id', + $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE) + ); + } +} \ No newline at end of file diff --git a/tests/Integration/Database/Relations/Fixtures/BelongsToOwnerModel.php b/tests/Integration/Database/Relations/Fixtures/BelongsToOwnerModel.php deleted file mode 100644 index 492ada80c..000000000 --- a/tests/Integration/Database/Relations/Fixtures/BelongsToOwnerModel.php +++ /dev/null @@ -1,29 +0,0 @@ -expectException(InvalidRelation::class); - $definition->getRelations('invalid'); - } - - public function test_inferred_has_many_relation(): void - { - $definition = new ModelDefinition(BelongsToRelationModel::class); - $inferredRelation = $definition->getRelations('inferred'); - - $this->assertCount(1, $inferredRelation); - $this->assertSame('belongs_to_related.inferred[]', $inferredRelation[0]->getRelationName()); - $this->assertEquals( - 'LEFT JOIN `belongs_to_parent_model` AS `belongs_to_related.inferred[]` ON `belongs_to_related`.`id` = `belongs_to_related.inferred[]`.`relatedModel_id`', - $inferredRelation[0]->getStatement(), - ); - } - - public function test_attribute_with_defaults_has_many_relation(): void - { - $definition = new ModelDefinition(BelongsToRelationModel::class); - $relation = $definition->getRelations('attribute'); - - $this->assertCount(1, $relation); - $this->assertSame('belongs_to_related.attribute[]', $relation[0]->getRelationName()); - $this->assertEquals( - 'LEFT JOIN `belongs_to_parent_model` AS `belongs_to_related.attribute[]` ON `belongs_to_related`.`id` = `belongs_to_related.attribute[]`.`other_id`', - $relation[0]->getStatement(), - ); - } - - public function test_fully_filled_attribute_has_many_relation(): void - { - $definition = new ModelDefinition(BelongsToRelationModel::class); - $relation = $definition->getRelations('full'); - - $this->assertCount(1, $relation); - $this->assertSame('belongs_to_related.full[]', $relation[0]->getRelationName()); - $this->assertEquals( - 'LEFT JOIN `belongs_to_parent_model` AS `belongs_to_related.full[]` ON `belongs_to_related`.`other_id` = `belongs_to_related.full[]`.`other_id`', - $relation[0]->getStatement(), - ); - } -} diff --git a/tests/Integration/Database/Relations/HasOneRelationTest.php b/tests/Integration/Database/Relations/HasOneRelationTest.php deleted file mode 100644 index f1a73bd5e..000000000 --- a/tests/Integration/Database/Relations/HasOneRelationTest.php +++ /dev/null @@ -1,40 +0,0 @@ -expectException(InvalidRelation::class); - - $definition = new ModelDefinition(HasOneParentModel::class); - $definition->getRelations($relationName); - } - - public function test_has_one_relation(): void - { - $definition = new ModelDefinition(HasOneParentModel::class); - $autoResolvedRelation = $definition->getRelations('relatedModel'); - $namedRelation = $definition->getRelations('otherRelatedModel'); - - $this->assertCount(1, $autoResolvedRelation); - $this->assertCount(1, $namedRelation); - $this->assertSame('has_one_parent_model.relatedModel', $autoResolvedRelation[0]->getRelationName()); - $this->assertSame('has_one_parent_model.otherRelatedModel', $namedRelation[0]->getRelationName()); - } -} From 3f59a5b2d1fe02cd72bde4981dc4828f441046b1 Mon Sep 17 00:00:00 2001 From: brendt Date: Thu, 15 May 2025 08:53:32 +0200 Subject: [PATCH 09/28] wip --- .../database/src/Builder/ModelDefinition.php | 48 -------- .../Builder/Relations/BelongsToRelation.php | 67 ----------- .../src/Builder/Relations/HasManyRelation.php | 97 ---------------- .../src/Builder/Relations/HasOneRelation.php | 109 ------------------ .../src/Builder/Relations/Relation.php | 18 --- 5 files changed, 339 deletions(-) delete mode 100644 packages/database/src/Builder/Relations/BelongsToRelation.php delete mode 100644 packages/database/src/Builder/Relations/HasManyRelation.php delete mode 100644 packages/database/src/Builder/Relations/HasOneRelation.php delete mode 100644 packages/database/src/Builder/Relations/Relation.php diff --git a/packages/database/src/Builder/ModelDefinition.php b/packages/database/src/Builder/ModelDefinition.php index b25b919e9..85f1dfe72 100644 --- a/packages/database/src/Builder/ModelDefinition.php +++ b/packages/database/src/Builder/ModelDefinition.php @@ -5,14 +5,7 @@ namespace Tempest\Database\Builder; use ReflectionException; -use Tempest\Database\BelongsTo; -use Tempest\Database\Builder\Relations\BelongsToRelation; -use Tempest\Database\Builder\Relations\HasManyRelation; -use Tempest\Database\Builder\Relations\HasOneRelation; use Tempest\Database\Config\DatabaseConfig; -use Tempest\Database\Eager; -use Tempest\Database\HasMany; -use Tempest\Database\HasOne; use Tempest\Database\Table; use Tempest\Reflection\ClassReflector; use Tempest\Support\Arr\ImmutableArray; @@ -41,47 +34,6 @@ public function __construct(string|object $model) } } - /** @return \Tempest\Database\Builder\Relations\Relation[] */ - public function getRelations(string $relationName): array - { - $relations = []; - $relationNames = explode('.', $relationName); - $alias = $this->getTableDefinition()->name; - $class = $this->modelClass; - - foreach ($relationNames as $relationNamePart) { - $property = $class->getProperty($relationNamePart); - - if ($property->hasAttribute(HasMany::class)) { - /** @var HasMany $relationAttribute */ - $relationAttribute = $property->getAttribute(HasMany::class); - $relations[] = HasManyRelation::fromAttribute($relationAttribute, $property, $alias); - $class = HasManyRelation::getRelationModelClass($property, $relationAttribute)->getType()->asClass(); - $alias .= ".{$property->getName()}"; - } elseif ($property->getType()->isIterable()) { - $relations[] = HasManyRelation::fromInference($property, $alias); - $class = $property->getIterableType()->asClass(); - $alias .= ".{$property->getName()}[]"; - } elseif ($property->hasAttribute(HasOne::class)) { - $relations[] = new HasOneRelation($property, $alias); - $class = $property->getType()->asClass(); - $alias .= ".{$property->getName()}"; - } elseif ($property->hasAttribute(BelongsTo::class)) { - /** @var BelongsTo $relationAttribute */ - $relationAttribute = $property->getAttribute(BelongsTo::class); - $relations[] = BelongsToRelation::fromAttribute($relationAttribute, $property, $alias); - $class = $property->getType()->asClass(); - $alias .= ".{$property->getName()}"; - } else { - $relations[] = BelongsToRelation::fromInference($property, $alias); - $class = $property->getType()->asClass(); - $alias .= ".{$property->getName()}"; - } - } - - return $relations; - } - public function getTableDefinition(): TableDefinition { $specificName = $this->modelClass diff --git a/packages/database/src/Builder/Relations/BelongsToRelation.php b/packages/database/src/Builder/Relations/BelongsToRelation.php deleted file mode 100644 index efbf84fca..000000000 --- a/packages/database/src/Builder/Relations/BelongsToRelation.php +++ /dev/null @@ -1,67 +0,0 @@ -getType()->asClass(); - - $localTable = TableDefinition::for($property->getClass(), $alias); - $localField = new FieldDefinition($localTable, $property->getName() . '_id'); - - $joinTable = TableDefinition::for($property->getType()->asClass(), "{$alias}.{$property->getName()}"); - $joinField = new FieldDefinition($joinTable, 'id'); - - return new self($relationModelClass, $localField, $joinField); - } - - public static function fromAttribute(BelongsTo $belongsTo, PropertyReflector $property, string $alias): self - { - $relationModelClass = $property->getType()->asClass(); - - $localTable = TableDefinition::for($property->getClass(), $alias); - $localField = new FieldDefinition($localTable, $belongsTo->ownerJoin); - - $joinTable = TableDefinition::for($property->getType()->asClass(), "{$alias}.{$property->getName()}"); - $joinField = new FieldDefinition($joinTable, $belongsTo->relationJoin); - - return new self($relationModelClass, $localField, $joinField); - } - - public function getStatement(): string - { - return sprintf( - 'LEFT JOIN %s ON %s = %s', - $this->joinField->tableDefinition, - $this->localField, - $this->joinField, - ); - } - - public function getRelationName(): string - { - return $this->joinField->tableDefinition->as; - } - - public function getFieldDefinitions(): ImmutableArray - { - return FieldDefinition::all($this->relationModelClass, $this->joinField->tableDefinition); - } -} diff --git a/packages/database/src/Builder/Relations/HasManyRelation.php b/packages/database/src/Builder/Relations/HasManyRelation.php deleted file mode 100644 index 276e1f0b8..000000000 --- a/packages/database/src/Builder/Relations/HasManyRelation.php +++ /dev/null @@ -1,97 +0,0 @@ -getPublicProperties() as $potentialInverseProperty) { - if ($potentialInverseProperty->getType()->equals($property->getClass()->getType())) { - $inverseProperty = $potentialInverseProperty; - - break; - } - } - - if ($inverseProperty === null) { - throw InvalidRelation::inversePropertyNotFound( - $property->getClass()->getName(), - $property->getName(), - $relationModelClass->getName(), - ); - } - - $localTable = TableDefinition::for($property->getClass(), $alias); - $localField = new FieldDefinition($localTable, 'id'); - - $joinTable = TableDefinition::for($relationModelClass, "{$alias}.{$property->getName()}[]"); - $joinField = new FieldDefinition($joinTable, $inverseProperty->getName() . '_id'); - - return new self($relationModelClass, $localField, $joinField); - } - - public static function getRelationModelClass( - PropertyReflector $property, - ?HasMany $relation = null, - ): ClassReflector { - if ($relation !== null && $relation->inverseClassName !== null) { - return new ClassReflector($relation->inverseClassName); - } - - return $property->getIterableType()->asClass(); - } - - public static function fromAttribute(HasMany $relation, PropertyReflector $property, string $alias): self - { - $relationModelClass = self::getRelationModelClass($property, $relation); - - $localTable = TableDefinition::for($property->getClass(), $alias); - $localField = new FieldDefinition($localTable, $relation->localPropertyName); - - $joinTable = TableDefinition::for($relationModelClass, "{$alias}.{$property->getName()}[]"); - $joinField = new FieldDefinition($joinTable, $relation->inversePropertyName); - - return new self($relationModelClass, $localField, $joinField); - } - - public function getStatement(): string - { - return sprintf( - 'LEFT JOIN %s ON %s = %s', - $this->joinField->tableDefinition, - $this->localField, - $this->joinField, - ); - } - - public function getRelationName(): string - { - return $this->joinField->tableDefinition->as; - } - - public function getFieldDefinitions(): ImmutableArray - { - return FieldDefinition::all($this->relationModelClass, $this->joinField->tableDefinition); - } -} diff --git a/packages/database/src/Builder/Relations/HasOneRelation.php b/packages/database/src/Builder/Relations/HasOneRelation.php deleted file mode 100644 index eba6d1a5c..000000000 --- a/packages/database/src/Builder/Relations/HasOneRelation.php +++ /dev/null @@ -1,109 +0,0 @@ -getAttribute(HasOne::class); - $inversePropertyName = $hasOneAttribute?->inversePropertyName; - - $inverseProperty = $inversePropertyName === null - ? $this->findInversePropertyByType($property) - : $this->findInversePropertyByName($property, $inversePropertyName); - - $this->relationModelClass = $property->getType()->asClass(); - - $localTable = TableDefinition::for($property->getClass(), $alias); - $this->localField = new FieldDefinition($localTable, 'id'); - - $joinTable = TableDefinition::for($property->getType()->asClass(), "{$alias}.{$property->getName()}"); - $this->joinField = new FieldDefinition($joinTable, $inverseProperty->getName() . '_id'); - } - - public function getStatement(): string - { - return sprintf( - 'LEFT JOIN %s ON %s = %s', - $this->joinField->tableDefinition, - $this->localField, - $this->joinField, - ); - } - - public function getRelationName(): string - { - return $this->joinField->tableDefinition->as; - } - - public function getFieldDefinitions(): ImmutableArray - { - return FieldDefinition::all($this->relationModelClass, $this->joinField->tableDefinition); - } - - private function findInversePropertyByType(PropertyReflector $property): PropertyReflector - { - $currentModelClass = $property->getClass(); - $propertyClass = $property->getType()->asClass(); - - foreach ($propertyClass->getPublicProperties() as $possibleInverseProperty) { - if ($possibleInverseProperty->getType()->matches($currentModelClass->getName())) { - return $possibleInverseProperty; - } - } - - throw InvalidRelation::inversePropertyNotFound( - $currentModelClass->getName(), - $property->getName(), - $propertyClass->getName(), - ); - } - - private function findInversePropertyByName(PropertyReflector $property, string $inversePropertyName): PropertyReflector - { - $currentModelClass = $property->getClass(); - $relatedClass = $property->getType()->asClass(); - - if (! $relatedClass->hasProperty($inversePropertyName)) { - throw InvalidRelation::inversePropertyMissing( - $currentModelClass->getName(), - $property->getName(), - $relatedClass->getName(), - $inversePropertyName, - ); - } - - $inverseProperty = $relatedClass->getProperty($inversePropertyName); - $expectedType = $currentModelClass->getType(); - - if (! $inverseProperty->getType()->matches($expectedType->getName())) { - throw InvalidRelation::inversePropertyInvalidType( - $currentModelClass->getName(), - $property->getName(), - $relatedClass->getName(), - $inversePropertyName, - $property->getType()->getName(), - $inverseProperty->getType()->getName(), - ); - } - - return $inverseProperty; - } -} diff --git a/packages/database/src/Builder/Relations/Relation.php b/packages/database/src/Builder/Relations/Relation.php deleted file mode 100644 index 893818ab0..000000000 --- a/packages/database/src/Builder/Relations/Relation.php +++ /dev/null @@ -1,18 +0,0 @@ - */ - public function getFieldDefinitions(): ImmutableArray; -} From 783266158237c0f0a6531608da0747f1b0c6e78b Mon Sep 17 00:00:00 2001 From: brendt Date: Thu, 15 May 2025 08:54:11 +0200 Subject: [PATCH 10/28] wip --- packages/database/src/BelongsTo.php | 11 +++-------- packages/database/src/Builder/ModelInspector.php | 9 +++------ .../Builder/QueryBuilders/SelectQueryBuilder.php | 2 +- packages/database/src/HasMany.php | 1 + packages/database/src/HasOne.php | 1 + packages/database/src/Mappers/SelectModelMapper.php | 3 ++- .../database/src/QueryStatements/FieldStatement.php | 10 ++++++---- .../src/QueryStatements/SelectStatement.php | 8 ++++---- packages/database/src/Relation.php | 2 +- .../tests/QueryStatements/FieldStatementTest.php | 8 ++++++-- packages/reflection/src/PropertyAttribute.php | 2 +- .../AttributeImplementingPropertyAttribute.php | 2 +- ...tyWithAttributeImplementingPropertyAttribute.php | 2 +- .../reflection/tests/Fixtures/HasAttributeTest.php | 2 +- tests/Integration/Database/BelongsToTest.php | 3 ++- tests/Integration/Database/HasManyTest.php | 13 +++++++------ tests/Integration/Database/HasOneTest.php | 13 +++++++------ .../Database/Mappers/SelectModelMapperTest.php | 3 ++- 18 files changed, 50 insertions(+), 45 deletions(-) diff --git a/packages/database/src/BelongsTo.php b/packages/database/src/BelongsTo.php index fb6a412f0..f5e4f026b 100644 --- a/packages/database/src/BelongsTo.php +++ b/packages/database/src/BelongsTo.php @@ -10,6 +10,7 @@ use Tempest\Database\QueryStatements\JoinStatement; use Tempest\Reflection\PropertyReflector; use Tempest\Support\Arr\ImmutableArray; + use function Tempest\Support\str; #[Attribute(Attribute::TARGET_PROPERTY)] @@ -64,10 +65,7 @@ private function getRelationJoin(ModelInspector $relationModel): string $relationJoin = $this->relationJoin; if ($relationJoin && ! strpos($relationJoin, '.')) { - $relationJoin = sprintf('%s.%s', - $relationModel->getTableName(), - $relationJoin, - ); + $relationJoin = sprintf('%s.%s', $relationModel->getTableName(), $relationJoin); } if ($relationJoin) { @@ -86,10 +84,7 @@ private function getOwnerJoin(ModelInspector $ownerModel): string $ownerJoin = $this->ownerJoin; if ($ownerJoin && ! strpos($ownerJoin, '.')) { - $ownerJoin = sprintf('%s.%s', - $ownerModel->getTableName(), - $ownerJoin, - ); + $ownerJoin = sprintf('%s.%s', $ownerModel->getTableName(), $ownerJoin); } if ($ownerJoin) { diff --git a/packages/database/src/Builder/ModelInspector.php b/packages/database/src/Builder/ModelInspector.php index fe01217f3..fce88e54e 100644 --- a/packages/database/src/Builder/ModelInspector.php +++ b/packages/database/src/Builder/ModelInspector.php @@ -28,8 +28,7 @@ final class ModelInspector public function __construct( private object|string $model, - ) - { + ) { if ($this->model instanceof ClassReflector) { $this->modelClass = $this->model; } else { @@ -215,11 +214,9 @@ public function getSelectFields(): ImmutableArray public function getRelation(string|PropertyReflector $name): ?Relation { - $name = $name instanceof PropertyReflector ? $name->getName() : $name; + $name = ($name instanceof PropertyReflector) ? $name->getName() : $name; - return $this->getBelongsTo($name) - ?? $this->getHasOne($name) - ?? $this->getHasMany($name); + return $this->getBelongsTo($name) ?? $this->getHasOne($name) ?? $this->getHasMany($name); } public function getEagerRelations(): array diff --git a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php index 07e8336be..f93989e89 100644 --- a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php @@ -228,7 +228,7 @@ public function build(mixed ...$bindings): Query foreach ($this->getIncludedRelations() as $relation) { $this->select->fields = $this->select->fields->append( - ...$relation->getSelectFields() + ...$relation->getSelectFields(), ); $this->select->join[] = $relation->getJoinStatement(); diff --git a/packages/database/src/HasMany.php b/packages/database/src/HasMany.php index f9319c435..af44c1489 100644 --- a/packages/database/src/HasMany.php +++ b/packages/database/src/HasMany.php @@ -10,6 +10,7 @@ use Tempest\Database\QueryStatements\JoinStatement; use Tempest\Reflection\PropertyReflector; use Tempest\Support\Arr\ImmutableArray; + use function Tempest\Support\str; #[Attribute(Attribute::TARGET_PROPERTY)] diff --git a/packages/database/src/HasOne.php b/packages/database/src/HasOne.php index c5fb3cd17..c8b3f2eec 100644 --- a/packages/database/src/HasOne.php +++ b/packages/database/src/HasOne.php @@ -10,6 +10,7 @@ use Tempest\Database\QueryStatements\JoinStatement; use Tempest\Reflection\PropertyReflector; use Tempest\Support\Arr\ImmutableArray; + use function Tempest\Support\str; #[Attribute(Attribute::TARGET_PROPERTY)] diff --git a/packages/database/src/Mappers/SelectModelMapper.php b/packages/database/src/Mappers/SelectModelMapper.php index c358ddcbd..e66175f48 100644 --- a/packages/database/src/Mappers/SelectModelMapper.php +++ b/packages/database/src/Mappers/SelectModelMapper.php @@ -5,6 +5,7 @@ use Tempest\Database\Builder\ModelInspector; use Tempest\Discovery\SkipDiscovery; use Tempest\Mapper\Mapper; + use function Tempest\Database\model; use function Tempest\map; use function Tempest\Support\arr; @@ -77,4 +78,4 @@ private function normalizeFields(ModelInspector $model, array $rows): array return $data; } -} \ No newline at end of file +} diff --git a/packages/database/src/QueryStatements/FieldStatement.php b/packages/database/src/QueryStatements/FieldStatement.php index 6d0e43635..d7bf67046 100644 --- a/packages/database/src/QueryStatements/FieldStatement.php +++ b/packages/database/src/QueryStatements/FieldStatement.php @@ -37,10 +37,12 @@ public function compile(DatabaseDialect $dialect): string $field = arr(explode('.', $field)) ->map(fn (string $part) => trim($part, '` ')) - ->map(fn (string $part) => match ($dialect) { - DatabaseDialect::SQLITE => $part, - default => sprintf('`%s`', $part), - }) + ->map( + fn (string $part) => match ($dialect) { + DatabaseDialect::SQLITE => $part, + default => sprintf('`%s`', $part), + }, + ) ->implode('.'); if ($alias === null) { diff --git a/packages/database/src/QueryStatements/SelectStatement.php b/packages/database/src/QueryStatements/SelectStatement.php index 6acd5f8b7..22e4791fc 100644 --- a/packages/database/src/QueryStatements/SelectStatement.php +++ b/packages/database/src/QueryStatements/SelectStatement.php @@ -30,7 +30,7 @@ public function compile(DatabaseDialect $dialect): string ? '*' : $this->fields ->map(function (string|Stringable|FieldStatement $field) use ($dialect) { - if (! $field instanceof FieldStatement) { + if (! ($field instanceof FieldStatement)) { $field = new FieldStatement($field); } @@ -92,9 +92,9 @@ public function compile(DatabaseDialect $dialect): string /* TODO: this should be improved. * More specifically, \Tempest\Database\Builder\FieldDefinition should be aware of the dialect, * or the whole ORM should be refactored to use \Tempest\Database\QueryStatements\FieldStatement*/ -// if ($dialect === DatabaseDialect::SQLITE) { -// $compiled = $compiled->replace('`', ''); -// } + // if ($dialect === DatabaseDialect::SQLITE) { + // $compiled = $compiled->replace('`', ''); + // } return $compiled; } diff --git a/packages/database/src/Relation.php b/packages/database/src/Relation.php index c1438017f..bddccebdc 100644 --- a/packages/database/src/Relation.php +++ b/packages/database/src/Relation.php @@ -11,4 +11,4 @@ interface Relation extends PropertyAttribute public function getSelectFields(): ImmutableArray; public function getJoinStatement(): JoinStatement; -} \ No newline at end of file +} diff --git a/packages/database/tests/QueryStatements/FieldStatementTest.php b/packages/database/tests/QueryStatements/FieldStatementTest.php index 0d976739a..119ddbe40 100644 --- a/packages/database/tests/QueryStatements/FieldStatementTest.php +++ b/packages/database/tests/QueryStatements/FieldStatementTest.php @@ -69,12 +69,16 @@ public function test_with_alias(): void { $this->assertSame( 'authors.name AS `authors.name`', - new FieldStatement('authors.name')->withAlias()->compile(DatabaseDialect::SQLITE), + new FieldStatement('authors.name') + ->withAlias() + ->compile(DatabaseDialect::SQLITE), ); $this->assertSame( '`authors`.`name` AS `authors.name`', - new FieldStatement('`authors`.`name`')->withAlias()->compile(DatabaseDialect::MYSQL), + new FieldStatement('`authors`.`name`') + ->withAlias() + ->compile(DatabaseDialect::MYSQL), ); } } diff --git a/packages/reflection/src/PropertyAttribute.php b/packages/reflection/src/PropertyAttribute.php index c009db18c..0e669c9ce 100644 --- a/packages/reflection/src/PropertyAttribute.php +++ b/packages/reflection/src/PropertyAttribute.php @@ -8,4 +8,4 @@ interface PropertyAttribute set; get; } -} \ No newline at end of file +} diff --git a/packages/reflection/tests/Fixtures/AttributeImplementingPropertyAttribute.php b/packages/reflection/tests/Fixtures/AttributeImplementingPropertyAttribute.php index 2d6a42a68..f050c2a67 100644 --- a/packages/reflection/tests/Fixtures/AttributeImplementingPropertyAttribute.php +++ b/packages/reflection/tests/Fixtures/AttributeImplementingPropertyAttribute.php @@ -10,4 +10,4 @@ final class AttributeImplementingPropertyAttribute implements PropertyAttribute { public PropertyReflector $property; -} \ No newline at end of file +} diff --git a/packages/reflection/tests/Fixtures/ClassWithPropertyWithAttributeImplementingPropertyAttribute.php b/packages/reflection/tests/Fixtures/ClassWithPropertyWithAttributeImplementingPropertyAttribute.php index 30ac75e4b..390e56ad7 100644 --- a/packages/reflection/tests/Fixtures/ClassWithPropertyWithAttributeImplementingPropertyAttribute.php +++ b/packages/reflection/tests/Fixtures/ClassWithPropertyWithAttributeImplementingPropertyAttribute.php @@ -6,4 +6,4 @@ final class ClassWithPropertyWithAttributeImplementingPropertyAttribute { #[AttributeImplementingPropertyAttribute] public string $prop; -} \ No newline at end of file +} diff --git a/packages/reflection/tests/Fixtures/HasAttributeTest.php b/packages/reflection/tests/Fixtures/HasAttributeTest.php index 25e8097e9..6109609fa 100644 --- a/packages/reflection/tests/Fixtures/HasAttributeTest.php +++ b/packages/reflection/tests/Fixtures/HasAttributeTest.php @@ -15,4 +15,4 @@ public function test_property_attribute(): void $this->assertSame('prop', $attribute->property->getName()); } -} \ No newline at end of file +} diff --git a/tests/Integration/Database/BelongsToTest.php b/tests/Integration/Database/BelongsToTest.php index 5be4c0f5d..b3920e8cb 100644 --- a/tests/Integration/Database/BelongsToTest.php +++ b/tests/Integration/Database/BelongsToTest.php @@ -6,6 +6,7 @@ use Tempest\Database\Config\DatabaseDialect; use Tests\Tempest\Integration\Database\Fixtures\OwnerModel; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; + use function Tempest\Database\model; final class BelongsToTest extends FrameworkIntegrationTestCase @@ -74,4 +75,4 @@ public function test_belongs_to_with_owner_join_field_and_table(): void $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE), ); } -} \ No newline at end of file +} diff --git a/tests/Integration/Database/HasManyTest.php b/tests/Integration/Database/HasManyTest.php index 09690fef9..bdc48149c 100644 --- a/tests/Integration/Database/HasManyTest.php +++ b/tests/Integration/Database/HasManyTest.php @@ -6,6 +6,7 @@ use Tempest\Database\HasMany; use Tests\Tempest\Integration\Database\Fixtures\RelationModel; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; + use function Tempest\Database\model; final class HasManyTest extends FrameworkIntegrationTestCase @@ -18,7 +19,7 @@ public function test_has_many(): void $this->assertInstanceOf(HasMany::class, $relation); $this->assertSame( 'LEFT JOIN owner ON owner.relation_id = relation.id', - $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE) + $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE), ); } @@ -30,7 +31,7 @@ public function test_has_many_with_overwritten_owner_join_field(): void $this->assertInstanceOf(HasMany::class, $relation); $this->assertSame( 'LEFT JOIN owner ON owner.overwritten_id = relation.id', - $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE) + $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE), ); } @@ -42,7 +43,7 @@ public function test_has_many_with_overwritten_owner_join_field_and_table(): voi $this->assertInstanceOf(HasMany::class, $relation); $this->assertSame( 'LEFT JOIN owner ON overwritten.overwritten_id = relation.id', - $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE) + $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE), ); } @@ -54,7 +55,7 @@ public function test_has_many_with_overwritten_relation_join_field(): void $this->assertInstanceOf(HasMany::class, $relation); $this->assertSame( 'LEFT JOIN owner ON owner.relation_id = relation.overwritten_id', - $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE) + $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE), ); } @@ -66,7 +67,7 @@ public function test_has_many_with_overwritten_relation_join_field_and_table(): $this->assertInstanceOf(HasMany::class, $relation); $this->assertSame( 'LEFT JOIN owner ON owner.relation_id = overwritten.overwritten_id', - $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE) + $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE), ); } -} \ No newline at end of file +} diff --git a/tests/Integration/Database/HasOneTest.php b/tests/Integration/Database/HasOneTest.php index d29de4348..4d005b1a1 100644 --- a/tests/Integration/Database/HasOneTest.php +++ b/tests/Integration/Database/HasOneTest.php @@ -6,6 +6,7 @@ use Tempest\Database\HasOne; use Tests\Tempest\Integration\Database\Fixtures\HasOneRelationModel; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; + use function Tempest\Database\model; final class HasOneTest extends FrameworkIntegrationTestCase @@ -18,7 +19,7 @@ public function test_has_one(): void $this->assertInstanceOf(HasOne::class, $relation); $this->assertSame( 'LEFT JOIN owner ON owner.relation_id = relation.id', - $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE) + $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE), ); } @@ -30,7 +31,7 @@ public function test_has_one_with_overwritten_owner_join_field(): void $this->assertInstanceOf(HasOne::class, $relation); $this->assertSame( 'LEFT JOIN owner ON owner.overwritten_id = relation.id', - $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE) + $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE), ); } @@ -42,7 +43,7 @@ public function test_has_one_with_overwritten_owner_join_field_and_table(): void $this->assertInstanceOf(HasOne::class, $relation); $this->assertSame( 'LEFT JOIN owner ON overwritten.overwritten_id = relation.id', - $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE) + $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE), ); } @@ -54,7 +55,7 @@ public function test_has_one_with_overwritten_relation_join_field(): void $this->assertInstanceOf(HasOne::class, $relation); $this->assertSame( 'LEFT JOIN owner ON owner.relation_id = relation.overwritten_id', - $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE) + $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE), ); } @@ -66,7 +67,7 @@ public function test_has_one_with_overwritten_relation_join_field_and_table(): v $this->assertInstanceOf(HasOne::class, $relation); $this->assertSame( 'LEFT JOIN owner ON owner.relation_id = overwritten.overwritten_id', - $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE) + $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE), ); } -} \ No newline at end of file +} diff --git a/tests/Integration/Database/Mappers/SelectModelMapperTest.php b/tests/Integration/Database/Mappers/SelectModelMapperTest.php index 4bcd5d176..7b4fbc6f1 100644 --- a/tests/Integration/Database/Mappers/SelectModelMapperTest.php +++ b/tests/Integration/Database/Mappers/SelectModelMapperTest.php @@ -5,6 +5,7 @@ use Tempest\Database\Mappers\SelectModelMapper; use Tests\Tempest\Fixtures\Modules\Books\Models\Book; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; + use function Tempest\map; final class SelectModelMapperTest extends FrameworkIntegrationTestCase @@ -167,4 +168,4 @@ private function data(): array ], ]; } -} \ No newline at end of file +} From b353352bf94cdb8f6d456fb17d23ffa29f15247b Mon Sep 17 00:00:00 2001 From: brendt Date: Thu, 15 May 2025 08:55:27 +0200 Subject: [PATCH 11/28] wip --- packages/database/src/Builder/FieldDefinition.php | 1 + packages/database/src/Builder/ModelDefinition.php | 1 + .../database/src/Builder/QueryBuilders/SelectQueryBuilder.php | 4 ---- packages/database/src/Builder/TableDefinition.php | 1 + packages/database/src/Mappers/QueryToModelMapper.php | 2 +- 5 files changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/database/src/Builder/FieldDefinition.php b/packages/database/src/Builder/FieldDefinition.php index 59b025e8e..aa2a2363c 100644 --- a/packages/database/src/Builder/FieldDefinition.php +++ b/packages/database/src/Builder/FieldDefinition.php @@ -11,6 +11,7 @@ use function Tempest\get; +// TODO: remove final class FieldDefinition implements Stringable { public function __construct( diff --git a/packages/database/src/Builder/ModelDefinition.php b/packages/database/src/Builder/ModelDefinition.php index 85f1dfe72..5fe0985f0 100644 --- a/packages/database/src/Builder/ModelDefinition.php +++ b/packages/database/src/Builder/ModelDefinition.php @@ -12,6 +12,7 @@ use function Tempest\get; +// TODO: remove final readonly class ModelDefinition { private ClassReflector $modelClass; diff --git a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php index f93989e89..301262b5e 100644 --- a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php @@ -9,7 +9,6 @@ use Tempest\Database\Builder\ModelDefinition; use Tempest\Database\Builder\TableDefinition; use Tempest\Database\Id; -use Tempest\Database\Mappers\QueryToModelMapper; use Tempest\Database\Mappers\SelectModelMapper; use Tempest\Database\Query; use Tempest\Database\QueryStatements\FieldStatement; @@ -18,14 +17,11 @@ use Tempest\Database\QueryStatements\RawStatement; use Tempest\Database\QueryStatements\SelectStatement; use Tempest\Database\QueryStatements\WhereStatement; -use Tempest\Database\Virtual; use Tempest\Support\Arr\ImmutableArray; use Tempest\Support\Conditions\HasConditions; use function Tempest\Database\model; use function Tempest\map; -use function Tempest\reflect; -use function Tempest\Support\arr; /** * @template TModelClass of object diff --git a/packages/database/src/Builder/TableDefinition.php b/packages/database/src/Builder/TableDefinition.php index c2a343f05..c7a32183d 100644 --- a/packages/database/src/Builder/TableDefinition.php +++ b/packages/database/src/Builder/TableDefinition.php @@ -7,6 +7,7 @@ use Stringable; use Tempest\Reflection\ClassReflector; +// TODO: remove final readonly class TableDefinition implements Stringable { public function __construct( diff --git a/packages/database/src/Mappers/QueryToModelMapper.php b/packages/database/src/Mappers/QueryToModelMapper.php index 667e51574..a812b507a 100644 --- a/packages/database/src/Mappers/QueryToModelMapper.php +++ b/packages/database/src/Mappers/QueryToModelMapper.php @@ -10,7 +10,7 @@ use Tempest\Reflection\ClassReflector; use Tempest\Reflection\PropertyReflector; -// TODO: refactor +// TODO: remove final readonly class QueryToModelMapper implements Mapper { public function __construct( From 1279dfd84cf8cb1e98772c7840fcfb51e4771f8a Mon Sep 17 00:00:00 2001 From: brendt Date: Thu, 15 May 2025 09:41:46 +0200 Subject: [PATCH 12/28] wip --- packages/database/src/BelongsTo.php | 19 ++++++++-- .../database/src/Builder/ModelInspector.php | 36 ++++++++++++++++++- .../QueryBuilders/SelectQueryBuilder.php | 8 ++--- packages/database/src/HasMany.php | 13 ++++++- packages/database/src/HasOne.php | 13 ++++++- .../src/QueryStatements/FieldStatement.php | 11 +++++- packages/database/src/Relation.php | 2 ++ .../QueryStatements/FieldStatementTest.php | 11 ++++++ tests/Integration/Database/BelongsToTest.php | 11 ++++++ .../Database/Fixtures/HasOneRelationModel.php | 2 ++ .../Database/Fixtures/OwnerModel.php | 2 ++ .../Database/Fixtures/RelationModel.php | 2 ++ tests/Integration/Database/HasManyTest.php | 12 +++++++ tests/Integration/Database/HasOneTest.php | 12 +++++++ tests/Integration/ORM/IsDatabaseModelTest.php | 8 +++-- 15 files changed, 150 insertions(+), 12 deletions(-) diff --git a/packages/database/src/BelongsTo.php b/packages/database/src/BelongsTo.php index f5e4f026b..1a907bb75 100644 --- a/packages/database/src/BelongsTo.php +++ b/packages/database/src/BelongsTo.php @@ -18,15 +18,28 @@ final class BelongsTo implements Relation { public PropertyReflector $property; + private ?string $parent = null; + public function __construct( private readonly ?string $relationJoin = null, private readonly ?string $ownerJoin = null, ) {} + public function setParent(string $name): self + { + $this->parent = $name; + + return $this; + } + public function getOwnerFieldName(): string { if ($this->ownerJoin) { - return explode('.', $this->ownerJoin)[1]; + if (strpos($this->ownerJoin, '.') !== false) { + return explode('.', $this->ownerJoin)[1]; + } else { + return $this->ownerJoin; + } } $relationModel = model($this->property->getType()->asClass()); @@ -40,7 +53,9 @@ public function getSelectFields(): ImmutableArray return $relationModel ->getSelectFields() - ->map(fn ($field) => new FieldStatement($relationModel->getTableName() . '.' . $field)->withAlias()); + ->map(fn ($field) => new FieldStatement( + $relationModel->getTableName() . '.' . $field + )->withAlias()->withAliasPrefix($this->parent)); } public function getJoinStatement(): JoinStatement diff --git a/packages/database/src/Builder/ModelInspector.php b/packages/database/src/Builder/ModelInspector.php index fce88e54e..d5a2808f2 100644 --- a/packages/database/src/Builder/ModelInspector.php +++ b/packages/database/src/Builder/ModelInspector.php @@ -18,6 +18,7 @@ use Tempest\Validation\SkipValidation; use Tempest\Validation\Validator; +use function Tempest\Database\model; use function Tempest\get; use function Tempest\Support\arr; use function Tempest\Support\str; @@ -28,7 +29,8 @@ final class ModelInspector public function __construct( private object|string $model, - ) { + ) + { if ($this->model instanceof ClassReflector) { $this->modelClass = $this->model; } else { @@ -219,6 +221,38 @@ public function getRelation(string|PropertyReflector $name): ?Relation return $this->getBelongsTo($name) ?? $this->getHasOne($name) ?? $this->getHasMany($name); } + public function resolveRelations(string $relationString, string $parent = ''): array + { + if ($relationString === '') { + return []; + } + + $relationNames = explode('.', $relationString); + + $currentRelationName = $relationNames[0]; + + $currentRelation = $this->getRelation($currentRelationName); + + if ($currentRelation === null) { + return []; + } + + unset($relationNames[0]); + + $newRelationString = implode('.', $relationNames); + $currentRelation->setParent($parent); + $newParent = ltrim(sprintf( + '%s.%s', + $parent, + $currentRelationName, + ), '.'); + + return [ + $currentRelation, + ...model($currentRelation->property->getType()->asClass())->resolveRelations($newRelationString, $newParent) + ]; + } + public function getEagerRelations(): array { if (! $this->isObjectModel()) { diff --git a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php index 301262b5e..7cdf2f4e7 100644 --- a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php @@ -258,14 +258,14 @@ private function getIncludedRelations(): array $relations = $definition->getEagerRelations(); - foreach ($this->relations as $relation) { - $relation = $definition->getRelation($relation); + foreach ($this->relations as $relationString) { + $resolvedRelations = $definition->resolveRelations($relationString); - if (! $relation) { + if ($resolvedRelations === []) { continue; } - $relations[$relation->property->getName()] = $relation; + $relations = [...$relations, ...$resolvedRelations]; } return $relations; diff --git a/packages/database/src/HasMany.php b/packages/database/src/HasMany.php index af44c1489..527bda15f 100644 --- a/packages/database/src/HasMany.php +++ b/packages/database/src/HasMany.php @@ -18,18 +18,29 @@ final class HasMany implements Relation { public PropertyReflector $property; + private ?string $parent = null; + public function __construct( public ?string $ownerJoin = null, public ?string $relationJoin = null, ) {} + public function setParent(string $name): self + { + $this->parent = $name; + + return $this; + } + public function getSelectFields(): ImmutableArray { $relationModel = model($this->property->getIterableType()->asClass()); return $relationModel ->getSelectFields() - ->map(fn ($field) => new FieldStatement($relationModel->getTableName() . '.' . $field)->withAlias()); + ->map(fn ($field) => new FieldStatement( + $relationModel->getTableName() . '.' . $field, + )->withAlias()->withAliasPrefix($this->parent)); } public function idField(): string diff --git a/packages/database/src/HasOne.php b/packages/database/src/HasOne.php index c8b3f2eec..3b1a04ceb 100644 --- a/packages/database/src/HasOne.php +++ b/packages/database/src/HasOne.php @@ -18,18 +18,29 @@ final class HasOne implements Relation { public PropertyReflector $property; + private ?string $parent = null; + public function __construct( public ?string $ownerJoin = null, public ?string $relationJoin = null, ) {} + public function setParent(string $name): self + { + $this->parent = $name; + + return $this; + } + public function getSelectFields(): ImmutableArray { $relationModel = model($this->property->getType()->asClass()); return $relationModel ->getSelectFields() - ->map(fn ($field) => new FieldStatement($relationModel->getTableName() . '.' . $field)->withAlias()); + ->map(fn ($field) => new FieldStatement( + $relationModel->getTableName() . '.' . $field, + )->withAlias()->withAliasPrefix($this->parent)); } public function getJoinStatement(): JoinStatement diff --git a/packages/database/src/QueryStatements/FieldStatement.php b/packages/database/src/QueryStatements/FieldStatement.php index d7bf67046..90906ff79 100644 --- a/packages/database/src/QueryStatements/FieldStatement.php +++ b/packages/database/src/QueryStatements/FieldStatement.php @@ -11,6 +11,7 @@ final class FieldStatement implements QueryStatement { private bool $withAlias = false; + private ?string $aliasPrefix = null; public function __construct( private readonly string|Stringable $field, @@ -27,7 +28,8 @@ public function compile(DatabaseDialect $dialect): string if ($this->withAlias) { $alias = sprintf( - '`%s`', + '`%s%s`', + $this->aliasPrefix ? "$this->aliasPrefix." : "", str_replace('`', '', $field), ); } @@ -52,6 +54,13 @@ public function compile(DatabaseDialect $dialect): string return sprintf('%s AS `%s`', $field, trim($alias, '`')); } + public function withAliasPrefix(?string $prefix = null): self + { + $this->aliasPrefix = $prefix; + + return $this; + } + public function withAlias(): self { $this->withAlias = true; diff --git a/packages/database/src/Relation.php b/packages/database/src/Relation.php index bddccebdc..5d1a635b5 100644 --- a/packages/database/src/Relation.php +++ b/packages/database/src/Relation.php @@ -8,6 +8,8 @@ interface Relation extends PropertyAttribute { + public function setParent(string $name): self; + public function getSelectFields(): ImmutableArray; public function getJoinStatement(): JoinStatement; diff --git a/packages/database/tests/QueryStatements/FieldStatementTest.php b/packages/database/tests/QueryStatements/FieldStatementTest.php index 119ddbe40..c2295598c 100644 --- a/packages/database/tests/QueryStatements/FieldStatementTest.php +++ b/packages/database/tests/QueryStatements/FieldStatementTest.php @@ -81,4 +81,15 @@ public function test_with_alias(): void ->compile(DatabaseDialect::MYSQL), ); } + + public function test_with_alias_prefix(): void + { + $this->assertSame( + 'authors.name AS `parent.authors.name`', + new FieldStatement('authors.name') + ->withAlias() + ->withAliasPrefix('parent') + ->compile(DatabaseDialect::SQLITE), + ); + } } diff --git a/tests/Integration/Database/BelongsToTest.php b/tests/Integration/Database/BelongsToTest.php index b3920e8cb..e238a08e7 100644 --- a/tests/Integration/Database/BelongsToTest.php +++ b/tests/Integration/Database/BelongsToTest.php @@ -75,4 +75,15 @@ public function test_belongs_to_with_owner_join_field_and_table(): void $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE), ); } + + public function test_belongs_to_with_parent(): void + { + $model = model(OwnerModel::class); + $relation = $model->getRelation('relation')->setParent('parent'); + + $this->assertSame( + 'relation.name AS `parent.relation.name`', + $relation->getSelectFields()[0]->compile(DatabaseDialect::SQLITE), + ); + } } diff --git a/tests/Integration/Database/Fixtures/HasOneRelationModel.php b/tests/Integration/Database/Fixtures/HasOneRelationModel.php index 72602a0af..da8331cfc 100644 --- a/tests/Integration/Database/Fixtures/HasOneRelationModel.php +++ b/tests/Integration/Database/Fixtures/HasOneRelationModel.php @@ -24,4 +24,6 @@ final class HasOneRelationModel #[HasOne(relationJoin: 'overwritten.overwritten_id')] public OwnerModel $relationJoinFieldAndTable; + + public string $name; } diff --git a/tests/Integration/Database/Fixtures/OwnerModel.php b/tests/Integration/Database/Fixtures/OwnerModel.php index 763bd61d7..3e8e70df6 100644 --- a/tests/Integration/Database/Fixtures/OwnerModel.php +++ b/tests/Integration/Database/Fixtures/OwnerModel.php @@ -23,4 +23,6 @@ final class OwnerModel #[BelongsTo(ownerJoin: 'overwritten.overwritten_id')] public RelationModel $ownerJoinFieldAndTable; + + public string $name; } diff --git a/tests/Integration/Database/Fixtures/RelationModel.php b/tests/Integration/Database/Fixtures/RelationModel.php index 4d7af04ae..1aa6aba4f 100644 --- a/tests/Integration/Database/Fixtures/RelationModel.php +++ b/tests/Integration/Database/Fixtures/RelationModel.php @@ -28,4 +28,6 @@ final class RelationModel /** @var \Tests\Tempest\Integration\Database\Fixtures\OwnerModel[] */ #[HasMany(relationJoin: 'overwritten.overwritten_id')] public array $relationJoinFieldAndTable = []; + + public string $name; } diff --git a/tests/Integration/Database/HasManyTest.php b/tests/Integration/Database/HasManyTest.php index bdc48149c..2cad07b6c 100644 --- a/tests/Integration/Database/HasManyTest.php +++ b/tests/Integration/Database/HasManyTest.php @@ -4,6 +4,7 @@ use Tempest\Database\Config\DatabaseDialect; use Tempest\Database\HasMany; +use Tests\Tempest\Integration\Database\Fixtures\OwnerModel; use Tests\Tempest\Integration\Database\Fixtures\RelationModel; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; @@ -70,4 +71,15 @@ public function test_has_many_with_overwritten_relation_join_field_and_table(): $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE), ); } + + public function test_has_many_with_parent(): void + { + $model = model(RelationModel::class); + $relation = $model->getRelation('owners')->setParent('parent'); + + $this->assertSame( + 'owner.relation_id AS `parent.owner.relation_id`', + $relation->getSelectFields()[0]->compile(DatabaseDialect::SQLITE), + ); + } } diff --git a/tests/Integration/Database/HasOneTest.php b/tests/Integration/Database/HasOneTest.php index 4d005b1a1..a7bb3543f 100644 --- a/tests/Integration/Database/HasOneTest.php +++ b/tests/Integration/Database/HasOneTest.php @@ -5,6 +5,7 @@ use Tempest\Database\Config\DatabaseDialect; use Tempest\Database\HasOne; use Tests\Tempest\Integration\Database\Fixtures\HasOneRelationModel; +use Tests\Tempest\Integration\Database\Fixtures\RelationModel; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; use function Tempest\Database\model; @@ -70,4 +71,15 @@ public function test_has_one_with_overwritten_relation_join_field_and_table(): v $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE), ); } + + public function test_has_one_with_parent(): void + { + $model = model(HasOneRelationModel::class); + $relation = $model->getRelation('owner')->setParent('parent'); + + $this->assertSame( + 'owner.relation_id AS `parent.owner.relation_id`', + $relation->getSelectFields()[0]->compile(DatabaseDialect::SQLITE), + ); + } } diff --git a/tests/Integration/ORM/IsDatabaseModelTest.php b/tests/Integration/ORM/IsDatabaseModelTest.php index 1db63508c..8f5858333 100644 --- a/tests/Integration/ORM/IsDatabaseModelTest.php +++ b/tests/Integration/ORM/IsDatabaseModelTest.php @@ -324,10 +324,14 @@ public function test_has_one_relation(): void new ThroughModel(parent: $parent, child: $childA, child2: $childB)->save(); - $child = ChildModel::get($childA->id, ['through.parent']); - $child2 = ChildModel::get($childB->id, ['through2.parent']); + $child = ChildModel::select() + ->with('through.parent') + ->get($childA->id); $this->assertSame('parent', $child->through->parent->name); + + $child2 = ChildModel::get($childB->id, ['through2.parent']); + $this->assertSame('parent', $child2->through2->parent->name); } From 9b71a17e3f6048eafed82fdf841ac881c6281904 Mon Sep 17 00:00:00 2001 From: brendt Date: Thu, 15 May 2025 10:12:23 +0200 Subject: [PATCH 13/28] wip --- .../database/src/Builder/ModelInspector.php | 51 ++++++++++++++----- .../QueryBuilders/CountQueryBuilder.php | 2 +- .../QueryBuilders/DeleteQueryBuilder.php | 5 ++ .../QueryBuilders/InsertQueryBuilder.php | 5 ++ .../QueryBuilders/SelectQueryBuilder.php | 4 +- .../QueryBuilders/UpdateQueryBuilder.php | 5 ++ .../src/Exceptions/QueryException.php | 2 +- packages/database/src/GenericDatabase.php | 4 +- .../src/Mappers/SelectModelMapper.php | 12 ++++- .../src/Migrations/MigrationManager.php | 2 +- packages/database/src/Query.php | 2 +- .../Builder/CountQueryBuilderTest.php | 12 ++--- .../Builder/DeleteQueryBuilderTest.php | 8 +-- .../Builder/InsertQueryBuilderTest.php | 10 ++-- .../Builder/SelectQueryBuilderTest.php | 12 ++--- .../Builder/UpdateQueryBuilderTest.php | 16 +++--- tests/Integration/ORM/IsDatabaseModelTest.php | 15 +++--- .../ORM/Mappers/QueryMapperTest.php | 4 +- tests/Integration/ORM/Models/ChildModel.php | 2 +- 19 files changed, 109 insertions(+), 64 deletions(-) diff --git a/packages/database/src/Builder/ModelInspector.php b/packages/database/src/Builder/ModelInspector.php index d5a2808f2..6eda436f4 100644 --- a/packages/database/src/Builder/ModelInspector.php +++ b/packages/database/src/Builder/ModelInspector.php @@ -27,19 +27,27 @@ final class ModelInspector { private ?ClassReflector $modelClass; - public function __construct( - private object|string $model, - ) + private object|string $model; + + public function __construct(object|string $model) { - if ($this->model instanceof ClassReflector) { - $this->modelClass = $this->model; + if ($model instanceof HasMany) { + $model = $model->property->getIterableType()->asClass(); + $this->modelClass = $model; + } elseif ($model instanceof BelongsTo || $model instanceof HasOne) { + $model = $model->property->getType()->asClass(); + $this->modelClass = $model; + } elseif ($model instanceof ClassReflector) { + $this->modelClass = $model; } else { try { - $this->modelClass = new ClassReflector($this->model); + $this->modelClass = new ClassReflector($model); } catch (ReflectionException) { $this->modelClass = null; } } + + $this->model = $model; } public function isObjectModel(): bool @@ -247,13 +255,12 @@ public function resolveRelations(string $relationString, string $parent = ''): a $currentRelationName, ), '.'); - return [ - $currentRelation, - ...model($currentRelation->property->getType()->asClass())->resolveRelations($newRelationString, $newParent) - ]; + $relations = [$currentRelation]; + + return [...$relations, ...model($currentRelation)->resolveRelations($newRelationString, $newParent)]; } - public function getEagerRelations(): array + public function resolveEagerRelations(string $parent = ''): array { if (! $this->isObjectModel()) { return []; @@ -262,8 +269,26 @@ public function getEagerRelations(): array $relations = []; foreach ($this->modelClass->getPublicProperties() as $property) { - if ($property->hasAttribute(Eager::class)) { - $relations[$property->getName()] = $this->getRelation($property); + if (! $property->hasAttribute(Eager::class)) { + continue; + } + + $currentRelationName = $property->getName(); + $currentRelation = $this->getRelation($currentRelationName); + + if (! $currentRelation) { + continue; + } + + $relations[$property->getName()] = $currentRelation->setParent($parent); + $newParent = ltrim(sprintf( + '%s.%s', + $parent, + $currentRelationName, + ), '.'); + + foreach (model($currentRelation)->resolveEagerRelations($newParent) as $name => $nestedEagerRelation) { + $relations[$name] = $nestedEagerRelation; } } diff --git a/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php index 440112220..37b8ce0f7 100644 --- a/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php @@ -90,7 +90,7 @@ public function bind(mixed ...$bindings): self public function toSql(): string { - return $this->build()->getSql(); + return $this->build()->toSql(); } public function build(mixed ...$bindings): Query diff --git a/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php index 530e52372..c8a71a892 100644 --- a/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php @@ -61,6 +61,11 @@ public function bind(mixed ...$bindings): self return $this; } + public function toSql(): string + { + return $this->build()->toSql(); + } + public function build(mixed ...$bindings): Query { return new Query($this->delete, [...$this->bindings, ...$bindings]); diff --git a/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php index 699b671ee..f8ad7f9d5 100644 --- a/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php @@ -45,6 +45,11 @@ public function execute(mixed ...$bindings): Id return $id; } + public function toSql(): string + { + return $this->build()->toSql(); + } + public function build(mixed ...$bindings): Query { $definition = model($this->model); diff --git a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php index 7cdf2f4e7..6ec66b71d 100644 --- a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php @@ -213,7 +213,7 @@ public function bind(mixed ...$bindings): self public function toSql(): string { - return $this->build()->getSql(); + return $this->build()->toSql(); } public function build(mixed ...$bindings): Query @@ -256,7 +256,7 @@ private function getIncludedRelations(): array return []; } - $relations = $definition->getEagerRelations(); + $relations = $definition->resolveEagerRelations(); foreach ($this->relations as $relationString) { $resolvedRelations = $definition->resolveRelations($relationString); diff --git a/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php index 8056f5220..4455aa707 100644 --- a/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php @@ -62,6 +62,11 @@ public function bind(mixed ...$bindings): self return $this; } + public function toSql(): string + { + return $this->build()->toSql(); + } + public function build(mixed ...$bindings): Query { $values = $this->resolveValues(); diff --git a/packages/database/src/Exceptions/QueryException.php b/packages/database/src/Exceptions/QueryException.php index 82f9dfb98..102aa2635 100644 --- a/packages/database/src/Exceptions/QueryException.php +++ b/packages/database/src/Exceptions/QueryException.php @@ -14,7 +14,7 @@ public function __construct(Query $query, array $bindings, PDOException $previou { $message = $previous->getMessage(); - $message .= PHP_EOL . PHP_EOL . $query->getSql() . PHP_EOL; + $message .= PHP_EOL . PHP_EOL . $query->toSql() . PHP_EOL; $message .= PHP_EOL . 'bindings: ' . json_encode($bindings, JSON_PRETTY_PRINT); diff --git a/packages/database/src/GenericDatabase.php b/packages/database/src/GenericDatabase.php index 4690318b1..0f7b7e1aa 100644 --- a/packages/database/src/GenericDatabase.php +++ b/packages/database/src/GenericDatabase.php @@ -26,7 +26,7 @@ public function execute(Query $query): void try { $this->connection - ->prepare($query->getSql()) + ->prepare($query->toSql()) ->execute($bindings); } catch (PDOException $pdoException) { throw new QueryException($query, $bindings, $pdoException); @@ -40,7 +40,7 @@ public function getLastInsertId(): Id public function fetch(Query $query): array { - $pdoQuery = $this->connection->prepare($query->getSql()); + $pdoQuery = $this->connection->prepare($query->toSql()); $pdoQuery->execute($this->resolveBindings($query)); diff --git a/packages/database/src/Mappers/SelectModelMapper.php b/packages/database/src/Mappers/SelectModelMapper.php index e66175f48..1da5357c1 100644 --- a/packages/database/src/Mappers/SelectModelMapper.php +++ b/packages/database/src/Mappers/SelectModelMapper.php @@ -54,19 +54,27 @@ private function normalizeFields(ModelInspector $model, array $rows): array // BelongsTo if ($belongsTo = $model->getBelongsTo($mainField)) { $data[$belongsTo->property->getName()][str_replace($mainField . '.', '', $field)] = $value; + continue; } // HasOne if ($hasOne = $model->getHasOne($mainField)) { $data[$hasOne->property->getName()][str_replace($mainField . '.', '', $field)] = $value; + continue; } // HasMany if ($hasMany = $model->getHasMany($mainField)) { - $hasManyRelations[$mainField] ??= $hasMany; - $hasManyId = $row[$hasMany->idField()]; + if ($hasManyId === null) { + // Empty has many relations are initialized it with an empty array + $data[$hasMany->property->getName()] ??= []; + continue; + } + + $hasManyRelations[$mainField] ??= $hasMany; + $data[$hasMany->property->getName()][$hasManyId][str_replace($mainField . '.', '', $field)] = $value; } } diff --git a/packages/database/src/Migrations/MigrationManager.php b/packages/database/src/Migrations/MigrationManager.php index ada1587ad..fb14929fb 100644 --- a/packages/database/src/Migrations/MigrationManager.php +++ b/packages/database/src/Migrations/MigrationManager.php @@ -281,7 +281,7 @@ private function getMinifiedSqlFromStatement(?QueryStatement $statement): string $query = new Query($statement->compile($this->databaseConfig->dialect)); // Remove comments - $sql = preg_replace('/--.*$/m', '', $query->getSql()); // Remove SQL single-line comments + $sql = preg_replace('/--.*$/m', '', $query->toSql()); // Remove SQL single-line comments $sql = preg_replace('/\/\*[\s\S]*?\*\//', '', $sql); // Remove block comments // Remove blank lines and excessive spaces diff --git a/packages/database/src/Query.php b/packages/database/src/Query.php index 97d6a8e4d..1457be5d3 100644 --- a/packages/database/src/Query.php +++ b/packages/database/src/Query.php @@ -44,7 +44,7 @@ public function fetchFirst(mixed ...$bindings): ?array return $this->getDatabase()->fetchFirst($this->withBindings($bindings)); } - public function getSql(): string + public function toSql(): string { $sql = $this->sql; diff --git a/tests/Integration/Database/Builder/CountQueryBuilderTest.php b/tests/Integration/Database/Builder/CountQueryBuilderTest.php index 19847807d..891869c04 100644 --- a/tests/Integration/Database/Builder/CountQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/CountQueryBuilderTest.php @@ -33,7 +33,7 @@ public function test_simple_count_query(): void OR `createdAt` > ? SQL; - $sql = $query->getSql(); + $sql = $query->toSql(); $bindings = $query->bindings; $this->assertSame($expected, $sql); @@ -46,7 +46,7 @@ public function test_count_query_with_specified_asterisk(): void ->count('*') ->build(); - $sql = $query->getSql(); + $sql = $query->toSql(); $expected = <<count('title')->build(); - $sql = $query->getSql(); + $sql = $query->toSql(); $expected = <<distinct() ->build(); - $sql = $query->getSql(); + $sql = $query->toSql(); $expected = <<count()->build(); - $sql = $query->getSql(); + $sql = $query->toSql(); $expected = << ? SQL; - $sql = $query->getSql(); + $sql = $query->toSql(); $bindings = $query->bindings; $this->assertSame($expected, $sql); diff --git a/tests/Integration/Database/Builder/DeleteQueryBuilderTest.php b/tests/Integration/Database/Builder/DeleteQueryBuilderTest.php index b8c9aec96..37dd11d4e 100644 --- a/tests/Integration/Database/Builder/DeleteQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/DeleteQueryBuilderTest.php @@ -25,7 +25,7 @@ public function test_delete_on_plain_table(): void DELETE FROM `foo` WHERE `bar` = ? SQL, - $query->getSql(), + $query->toSql(), ); $this->assertSame( @@ -45,7 +45,7 @@ public function test_delete_on_model_table(): void <<getSql(), + $query->toSql(), ); } @@ -63,7 +63,7 @@ public function test_delete_on_model_object(): void DELETE FROM `authors` WHERE `id` = :id SQL, - $query->getSql(), + $query->toSql(), ); $this->assertSame( @@ -91,7 +91,7 @@ public function test_delete_on_plain_table_with_conditions(): void DELETE FROM `foo` WHERE `bar` = ? SQL, - $query->getSql(), + $query->toSql(), ); $this->assertSame( diff --git a/tests/Integration/Database/Builder/InsertQueryBuilderTest.php b/tests/Integration/Database/Builder/InsertQueryBuilderTest.php index bd6c9c938..6a2c73cf9 100644 --- a/tests/Integration/Database/Builder/InsertQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/InsertQueryBuilderTest.php @@ -34,7 +34,7 @@ public function test_insert_on_plain_table(): void INSERT INTO `chapters` (`title`, `index`) VALUES (?, ?) SQL, - $query->getSql(), + $query->toSql(), ); $this->assertSame( @@ -60,7 +60,7 @@ public function test_insert_with_batch(): void INSERT INTO `chapters` (`chapter`, `index`) VALUES (?, ?), (?, ?), (?, ?) SQL, - $query->getSql(), + $query->toSql(), ); $this->assertSame( @@ -88,7 +88,7 @@ public function test_insert_on_model_table(): void VALUES (?, ?), (?, ?) SQL; - $this->assertSame($expected, $query->getSql()); + $this->assertSame($expected, $query->toSql()); $this->assertSame(['brent', 'a', 'other name', 'b'], $query->bindings); } @@ -112,7 +112,7 @@ public function test_insert_on_model_table_with_new_relation(): void VALUES (?, ?) SQL; - $this->assertSame($expectedBookQuery, $bookQuery->getSql()); + $this->assertSame($expectedBookQuery, $bookQuery->toSql()); $this->assertSame('Timeline Taxi', $bookQuery->bindings[0]); $this->assertInstanceOf(Query::class, $bookQuery->bindings[1]); @@ -148,7 +148,7 @@ public function test_insert_on_model_table_with_existing_relation(): void VALUES (?, ?) SQL; - $this->assertSame($expectedBookQuery, $bookQuery->getSql()); + $this->assertSame($expectedBookQuery, $bookQuery->toSql()); $this->assertSame('Timeline Taxi', $bookQuery->bindings[0]); $this->assertSame(10, $bookQuery->bindings[1]); } diff --git a/tests/Integration/Database/Builder/SelectQueryBuilderTest.php b/tests/Integration/Database/Builder/SelectQueryBuilderTest.php index fe4239ae7..ff85aba6f 100644 --- a/tests/Integration/Database/Builder/SelectQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/SelectQueryBuilderTest.php @@ -41,7 +41,7 @@ public function test_select_query(): void ORDER BY `index` ASC SQL; - $sql = $query->getSql(); + $sql = $query->toSql(); $bindings = $query->bindings; $this->assertSame($expected, $sql); @@ -52,7 +52,7 @@ public function test_select_without_any_fields_specified(): void { $query = query('chapters')->select()->build(); - $sql = $query->getSql(); + $sql = $query->toSql(); $expected = <<select()->build(); - $sql = $query->getSql(); + $sql = $query->toSql(); $expected = <<getSql(); + $sql = $query->toSql(); $bindings = $query->bindings; $this->assertSame($expected, $sql); @@ -301,7 +301,7 @@ public function test_select_includes_belongs_to(): void $this->assertSame(<<build()->getSql()); + SQL, $query->build()->toSql()); } public function test_with_belongs_to_relation(): void @@ -317,7 +317,7 @@ public function test_with_belongs_to_relation(): void LEFT JOIN authors ON authors.id = books.author_id LEFT JOIN chapters ON chapters.book_id = books.id LEFT JOIN isbns ON isbns.book_id = books.id - SQL, $query->getSql()); + SQL, $query->toSql()); } public function test_select_query_execute_with_relations(): void diff --git a/tests/Integration/Database/Builder/UpdateQueryBuilderTest.php b/tests/Integration/Database/Builder/UpdateQueryBuilderTest.php index 1ef2c9cd6..f5e2f0500 100644 --- a/tests/Integration/Database/Builder/UpdateQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/UpdateQueryBuilderTest.php @@ -37,7 +37,7 @@ public function test_update_on_plain_table(): void SET `title` = ?, `index` = ? WHERE `id` = ? SQL, - $query->getSql(), + $query->toSql(), ); $this->assertSame( @@ -58,7 +58,7 @@ public function test_global_update(): void UPDATE `chapters` SET `index` = ? SQL, - $query->getSql(), + $query->toSql(), ); $this->assertSame( @@ -74,7 +74,7 @@ public function test_global_update_fails_without_allow_all(): void query('chapters') ->update(index: 0) ->build() - ->getSql(); + ->toSql(); } public function test_model_update_with_values(): void @@ -92,7 +92,7 @@ public function test_model_update_with_values(): void SET `title` = ? WHERE `id` = ? SQL, - $query->getSql(), + $query->toSql(), ); $this->assertSame( @@ -120,7 +120,7 @@ public function test_model_update_with_object(): void SET `title` = ? WHERE `id` = ? SQL, - $query->getSql(), + $query->toSql(), ); $this->assertSame( @@ -163,7 +163,7 @@ public function test_insert_new_relation_on_update(): void SET `author_id` = ? WHERE `id` = ? SQL, - $bookQuery->getSql(), + $bookQuery->toSql(), ); $this->assertInstanceOf(Query::class, $bookQuery->bindings[0]); @@ -197,7 +197,7 @@ public function test_attach_existing_relation_on_update(): void SET `author_id` = ? WHERE `id` = ? SQL, - $bookQuery->getSql(), + $bookQuery->toSql(), ); $this->assertSame([5, 10], $bookQuery->bindings); @@ -254,7 +254,7 @@ public function test_update_on_plain_table_with_conditions(): void SET `title` = ?, `index` = ? WHERE `id` = ? SQL, - $query->getSql(), + $query->toSql(), ); $this->assertSame( diff --git a/tests/Integration/ORM/IsDatabaseModelTest.php b/tests/Integration/ORM/IsDatabaseModelTest.php index 8f5858333..204fbe336 100644 --- a/tests/Integration/ORM/IsDatabaseModelTest.php +++ b/tests/Integration/ORM/IsDatabaseModelTest.php @@ -286,8 +286,8 @@ public function test_has_many_through_relation(): void $parent = ParentModel::get($parent->id, ['through.child']); - $this->assertSame('A', $parent->through[1]->child->name); - $this->assertSame('B', $parent->through[2]->child->name); + $this->assertSame('A', $parent->through[0]->child->name); + $this->assertSame('B', $parent->through[1]->child->name); } public function test_empty_has_many_relation(): void @@ -301,7 +301,7 @@ public function test_empty_has_many_relation(): void $parent = new ParentModel(name: 'parent')->save(); - $parent = ParentModel::get($parent->id, ['through.child']); + $parent = ParentModel::select()->with('through.child')->get($parent->id); $this->assertInstanceOf(ParentModel::class, $parent); $this->assertEmpty($parent->through); @@ -317,20 +317,16 @@ public function test_has_one_relation(): void ); $parent = new ParentModel(name: 'parent')->save(); - $childA = new ChildModel(name: 'A')->save(); - $childB = new ChildModel(name: 'B')->save(); new ThroughModel(parent: $parent, child: $childA, child2: $childB)->save(); - $child = ChildModel::select() - ->with('through.parent') - ->get($childA->id); + $child = ChildModel::select()->with('through.parent')->get($childA->id); $this->assertSame('parent', $child->through->parent->name); - $child2 = ChildModel::get($childB->id, ['through2.parent']); + $child2 = ChildModel::select()->with('through2.parent')->get($childB->id); $this->assertSame('parent', $child2->through2->parent->name); } @@ -400,6 +396,7 @@ public function test_eager_load(): void )->save(); $a = AWithEager::select()->first(); + $this->assertTrue(isset($a->b)); $this->assertTrue(isset($a->b->c)); } diff --git a/tests/Integration/ORM/Mappers/QueryMapperTest.php b/tests/Integration/ORM/Mappers/QueryMapperTest.php index 79915a76e..915235e15 100644 --- a/tests/Integration/ORM/Mappers/QueryMapperTest.php +++ b/tests/Integration/ORM/Mappers/QueryMapperTest.php @@ -27,7 +27,7 @@ public function test_insert_query(): void $this->assertSame(<<<'SQL' INSERT INTO `authors` (`name`) VALUES (?) - SQL, $query->getSql()); + SQL, $query->toSql()); $this->assertSame(['test'], $query->bindings); } @@ -41,7 +41,7 @@ public function test_update_query(): void UPDATE `authors` SET `name` = ? WHERE `id` = ? - SQL, $query->getSql()); + SQL, $query->toSql()); $this->assertSame(['other', 1], $query->bindings); } diff --git a/tests/Integration/ORM/Models/ChildModel.php b/tests/Integration/ORM/Models/ChildModel.php index f515f7c62..164985615 100644 --- a/tests/Integration/ORM/Models/ChildModel.php +++ b/tests/Integration/ORM/Models/ChildModel.php @@ -16,7 +16,7 @@ final class ChildModel #[HasOne] public ThroughModel $through; - #[HasOne('child2')] + #[HasOne(ownerJoin: 'child2_id')] public ThroughModel $through2; public function __construct( From f563a134a0185cc8eed4ad8116c8b92802611c9a Mon Sep 17 00:00:00 2001 From: brendt Date: Thu, 15 May 2025 10:19:56 +0200 Subject: [PATCH 14/28] wip --- packages/database/src/BelongsTo.php | 8 +++++--- packages/database/src/HasMany.php | 4 +++- packages/database/src/HasOne.php | 4 +++- packages/database/src/QueryStatements/FieldStatement.php | 2 +- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/database/src/BelongsTo.php b/packages/database/src/BelongsTo.php index 1a907bb75..8eda16e63 100644 --- a/packages/database/src/BelongsTo.php +++ b/packages/database/src/BelongsTo.php @@ -35,7 +35,7 @@ public function setParent(string $name): self public function getOwnerFieldName(): string { if ($this->ownerJoin) { - if (strpos($this->ownerJoin, '.') !== false) { + if (str_contains($this->ownerJoin, '.')) { return explode('.', $this->ownerJoin)[1]; } else { return $this->ownerJoin; @@ -54,8 +54,10 @@ public function getSelectFields(): ImmutableArray return $relationModel ->getSelectFields() ->map(fn ($field) => new FieldStatement( - $relationModel->getTableName() . '.' . $field - )->withAlias()->withAliasPrefix($this->parent)); + $relationModel->getTableName() . '.' . $field, + ) + ->withAlias() + ->withAliasPrefix($this->parent)); } public function getJoinStatement(): JoinStatement diff --git a/packages/database/src/HasMany.php b/packages/database/src/HasMany.php index 527bda15f..e123844f6 100644 --- a/packages/database/src/HasMany.php +++ b/packages/database/src/HasMany.php @@ -40,7 +40,9 @@ public function getSelectFields(): ImmutableArray ->getSelectFields() ->map(fn ($field) => new FieldStatement( $relationModel->getTableName() . '.' . $field, - )->withAlias()->withAliasPrefix($this->parent)); + ) + ->withAlias() + ->withAliasPrefix($this->parent)); } public function idField(): string diff --git a/packages/database/src/HasOne.php b/packages/database/src/HasOne.php index 3b1a04ceb..5c7890896 100644 --- a/packages/database/src/HasOne.php +++ b/packages/database/src/HasOne.php @@ -40,7 +40,9 @@ public function getSelectFields(): ImmutableArray ->getSelectFields() ->map(fn ($field) => new FieldStatement( $relationModel->getTableName() . '.' . $field, - )->withAlias()->withAliasPrefix($this->parent)); + ) + ->withAlias() + ->withAliasPrefix($this->parent)); } public function getJoinStatement(): JoinStatement diff --git a/packages/database/src/QueryStatements/FieldStatement.php b/packages/database/src/QueryStatements/FieldStatement.php index 90906ff79..c47888afa 100644 --- a/packages/database/src/QueryStatements/FieldStatement.php +++ b/packages/database/src/QueryStatements/FieldStatement.php @@ -29,7 +29,7 @@ public function compile(DatabaseDialect $dialect): string if ($this->withAlias) { $alias = sprintf( '`%s%s`', - $this->aliasPrefix ? "$this->aliasPrefix." : "", + $this->aliasPrefix ? "{$this->aliasPrefix}." : '', str_replace('`', '', $field), ); } From 7d3950bfd22ca3bf63b81496e46f1c0355e30086 Mon Sep 17 00:00:00 2001 From: brendt Date: Thu, 15 May 2025 10:26:29 +0200 Subject: [PATCH 15/28] wip --- .../QueryBuilders/SelectQueryBuilder.php | 40 ++++++------------- .../Builder/InsertQueryBuilderTest.php | 8 ++-- .../Builder/SelectQueryBuilderTest.php | 6 +-- .../Builder/UpdateQueryBuilderTest.php | 2 +- 4 files changed, 21 insertions(+), 35 deletions(-) diff --git a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php index 6ec66b71d..1b42aae53 100644 --- a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php @@ -7,7 +7,7 @@ use Closure; use Tempest\Database\Builder\FieldDefinition; use Tempest\Database\Builder\ModelDefinition; -use Tempest\Database\Builder\TableDefinition; +use Tempest\Database\Builder\ModelInspector; use Tempest\Database\Id; use Tempest\Database\Mappers\SelectModelMapper; use Tempest\Database\Query; @@ -33,7 +33,7 @@ final class SelectQueryBuilder implements BuildsQuery /** @var class-string $modelClass */ private readonly string $modelClass; - private ?ModelDefinition $modelDefinition; + private ModelInspector $model; private SelectStatement $select; @@ -45,15 +45,14 @@ final class SelectQueryBuilder implements BuildsQuery public function __construct(string|object $model, ?ImmutableArray $fields = null) { - $this->modelDefinition = ModelDefinition::tryFrom($model); $this->modelClass = is_object($model) ? $model::class : $model; - $model = model($this->modelClass); + $this->model = model($this->modelClass); $this->select = new SelectStatement( - table: $this->resolveTable($model), - fields: $fields ?? $model - ->getSelectFields() - ->map(fn (string $fieldName) => new FieldStatement("{$model->getTableName()}.{$fieldName}")->withAlias()), + table: $this->model->getTableDefinition(), + fields: $fields ?? $this->model + ->getSelectFields() + ->map(fn (string $fieldName) => new FieldStatement("{$this->model->getTableName()}.{$fieldName}")->withAlias()), ); } @@ -64,7 +63,7 @@ public function first(mixed ...$bindings): mixed { $query = $this->build(...$bindings); - if (! $this->modelDefinition) { + if (! $this->model->isObjectModel()) { return $query->fetchFirst(); } @@ -92,7 +91,7 @@ public function all(mixed ...$bindings): array { $query = $this->build(...$bindings); - if (! $this->modelDefinition) { + if (! $this->model->isObjectModel()) { return $query->fetch(); } @@ -143,14 +142,10 @@ public function orWhere(string $where, mixed ...$bindings): self /** @return self */ public function whereField(string $field, mixed $value): self { - if ($this->modelDefinition) { - $field = $this->modelDefinition->getFieldDefinition($field); - } else { - $field = new FieldDefinition( - $this->resolveTable($this->modelClass), - $field, - ); - } + $field = new FieldDefinition( + $this->model->getTableDefinition(), + $field, + ); return $this->where("{$field} = :{$field->name}", ...[$field->name => $value]); } @@ -238,15 +233,6 @@ private function clone(): self return clone $this; } - private function resolveTable(string|object $model): TableDefinition - { - if ($this->modelDefinition === null) { - return new TableDefinition($model); - } - - return $this->modelDefinition->getTableDefinition(); - } - /** @return \Tempest\Database\Relation[] */ private function getIncludedRelations(): array { diff --git a/tests/Integration/Database/Builder/InsertQueryBuilderTest.php b/tests/Integration/Database/Builder/InsertQueryBuilderTest.php index 6a2c73cf9..78aa87dd6 100644 --- a/tests/Integration/Database/Builder/InsertQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/InsertQueryBuilderTest.php @@ -123,7 +123,7 @@ public function test_insert_on_model_table_with_new_relation(): void VALUES (?) SQL; - $this->assertSame($expectedAuthorQuery, $authorQuery->getSql()); + $this->assertSame($expectedAuthorQuery, $authorQuery->toSql()); $this->assertSame('Brent', $authorQuery->bindings[0]); } @@ -201,9 +201,9 @@ public function test_then_method(): void $book = Book::select()->with('chapters')->get($id); $this->assertCount(3, $book->chapters); - $this->assertSame('Chapter 01', $book->chapters[1]->title); - $this->assertSame('Chapter 02', $book->chapters[2]->title); - $this->assertSame('Chapter 03', $book->chapters[3]->title); + $this->assertSame('Chapter 01', $book->chapters[0]->title); + $this->assertSame('Chapter 02', $book->chapters[1]->title); + $this->assertSame('Chapter 03', $book->chapters[2]->title); } public function test_insert_with_non_object_model(): void diff --git a/tests/Integration/Database/Builder/SelectQueryBuilderTest.php b/tests/Integration/Database/Builder/SelectQueryBuilderTest.php index ff85aba6f..624339083 100644 --- a/tests/Integration/Database/Builder/SelectQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/SelectQueryBuilderTest.php @@ -33,7 +33,7 @@ public function test_select_query(): void ->build(); $expected = << ? @@ -69,7 +69,7 @@ public function test_select_from_model(): void $sql = $query->toSql(); $expected = <<build(); $expected = << ? diff --git a/tests/Integration/Database/Builder/UpdateQueryBuilderTest.php b/tests/Integration/Database/Builder/UpdateQueryBuilderTest.php index f5e2f0500..baa4059a1 100644 --- a/tests/Integration/Database/Builder/UpdateQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/UpdateQueryBuilderTest.php @@ -175,7 +175,7 @@ public function test_insert_new_relation_on_update(): void INSERT INTO `authors` (`name`) VALUES (?) SQL, - $authorQuery->getSql(), + $authorQuery->toSql(), ); $this->assertSame(['Brent'], $authorQuery->bindings); From 1eb797a94661cfff2adbdeb762e7de61ba003dc2 Mon Sep 17 00:00:00 2001 From: brendt Date: Thu, 15 May 2025 10:58:17 +0200 Subject: [PATCH 16/28] wip --- packages/auth/src/Install/User.php | 2 +- packages/auth/src/Install/UserPermission.php | 7 +++---- .../database/src/Builder/ModelInspector.php | 18 +++++++++++++----- packages/database/src/IsDatabaseModel.php | 2 +- .../database/src/Mappers/SelectModelMapper.php | 2 +- tests/Integration/Auth/UserModelTest.php | 2 ++ .../Builder/SelectQueryBuilderTest.php | 13 +++++++++++++ 7 files changed, 34 insertions(+), 12 deletions(-) diff --git a/packages/auth/src/Install/User.php b/packages/auth/src/Install/User.php index 647a8ef76..36d457201 100644 --- a/packages/auth/src/Install/User.php +++ b/packages/auth/src/Install/User.php @@ -40,7 +40,7 @@ public function grantPermission(string|UnitEnum|Permission $permission): self { $permission = $this->resolvePermission($permission); - new UserPermission( + UserPermission::new( user: $this, permission: $permission, )->save(); diff --git a/packages/auth/src/Install/UserPermission.php b/packages/auth/src/Install/UserPermission.php index bab55629a..18cb67a2a 100644 --- a/packages/auth/src/Install/UserPermission.php +++ b/packages/auth/src/Install/UserPermission.php @@ -10,8 +10,7 @@ final class UserPermission { use IsDatabaseModel; - public function __construct( - public User $user, - public Permission $permission, - ) {} + public User $user; + + public Permission $permission; } diff --git a/packages/database/src/Builder/ModelInspector.php b/packages/database/src/Builder/ModelInspector.php index 6eda436f4..3709d706d 100644 --- a/packages/database/src/Builder/ModelInspector.php +++ b/packages/database/src/Builder/ModelInspector.php @@ -112,7 +112,9 @@ public function getBelongsTo(string $name): ?BelongsTo return null; } - $singularizedName = str($name)->singularizeLastWord(); + $name = str($name)->camel(); + + $singularizedName = $name->singularizeLastWord(); if (! $singularizedName->equals($name)) { return $this->getBelongsTo($singularizedName); @@ -148,7 +150,9 @@ public function getHasOne(string $name): ?HasOne return null; } - $singularizedName = str($name)->singularizeLastWord(); + $name = str($name)->camel(); + + $singularizedName = $name->singularizeLastWord(); if (! $singularizedName->equals($name)) { return $this->getHasOne($singularizedName); @@ -173,6 +177,8 @@ public function getHasMany(string $name): ?HasMany return null; } + $name = str($name)->camel(); + if (! $this->modelClass->hasProperty($name)) { return null; } @@ -247,17 +253,19 @@ public function resolveRelations(string $relationString, string $parent = ''): a unset($relationNames[0]); + $relationModel = model($currentRelation); + $newRelationString = implode('.', $relationNames); $currentRelation->setParent($parent); $newParent = ltrim(sprintf( '%s.%s', $parent, - $currentRelationName, + $relationModel->getTableName(), ), '.'); - $relations = [$currentRelation]; + $relations = [$relationModel->getTableName() => $currentRelation]; - return [...$relations, ...model($currentRelation)->resolveRelations($newRelationString, $newParent)]; + return [...$relations, ...$relationModel->resolveRelations($newRelationString, $newParent)]; } public function resolveEagerRelations(string $parent = ''): array diff --git a/packages/database/src/IsDatabaseModel.php b/packages/database/src/IsDatabaseModel.php index a6434c41b..bb9eb8a9b 100644 --- a/packages/database/src/IsDatabaseModel.php +++ b/packages/database/src/IsDatabaseModel.php @@ -120,7 +120,7 @@ public function __get(string $name): mixed $type = $property->getType(); - if ($type->isIterable()) { + if ($type->isRelation()) { throw new MissingRelation($this, $name); } diff --git a/packages/database/src/Mappers/SelectModelMapper.php b/packages/database/src/Mappers/SelectModelMapper.php index 1da5357c1..adbe34be1 100644 --- a/packages/database/src/Mappers/SelectModelMapper.php +++ b/packages/database/src/Mappers/SelectModelMapper.php @@ -79,7 +79,7 @@ private function normalizeFields(ModelInspector $model, array $rows): array } } } - +ld($data, $hasManyRelations); foreach ($hasManyRelations as $name => $hasMany) { $data[$name] = array_values($data[$name]); } diff --git a/tests/Integration/Auth/UserModelTest.php b/tests/Integration/Auth/UserModelTest.php index 824022479..a5450a1ef 100644 --- a/tests/Integration/Auth/UserModelTest.php +++ b/tests/Integration/Auth/UserModelTest.php @@ -9,10 +9,12 @@ use Tempest\Auth\Install\CreateUsersTable; use Tempest\Auth\Install\Permission; use Tempest\Auth\Install\User; +use Tempest\Auth\Install\UserPermission; use Tempest\Database\Migrations\CreateMigrationsTable; use Tests\Tempest\Integration\Auth\Fixtures\UserPermissionBackedEnum; use Tests\Tempest\Integration\Auth\Fixtures\UserPermissionUnitEnum; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; +use function Tempest\Database\query; /** * @internal diff --git a/tests/Integration/Database/Builder/SelectQueryBuilderTest.php b/tests/Integration/Database/Builder/SelectQueryBuilderTest.php index 624339083..263e0c689 100644 --- a/tests/Integration/Database/Builder/SelectQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/SelectQueryBuilderTest.php @@ -10,6 +10,7 @@ use Tests\Tempest\Fixtures\Migrations\CreateBookTable; use Tests\Tempest\Fixtures\Migrations\CreateChapterTable; use Tests\Tempest\Fixtures\Migrations\CreateIsbnTable; +use Tests\Tempest\Fixtures\Models\AWithEager; use Tests\Tempest\Fixtures\Modules\Books\Models\Author; use Tests\Tempest\Fixtures\Modules\Books\Models\AuthorType; use Tests\Tempest\Fixtures\Modules\Books\Models\Book; @@ -346,6 +347,18 @@ public function test_select_query_execute_with_relations(): void $this->assertSame('lotr-1', $book->isbn->value); } + public function test_eager_loads_combined_with_manual_loads(): void + { + $query = AWithEager::select()->with('b.c')->toSql(); + + $this->assertSame(<<migrate( From 2d40bed122aa60957a0b5150907608886884db89 Mon Sep 17 00:00:00 2001 From: brendt Date: Thu, 15 May 2025 11:16:18 +0200 Subject: [PATCH 17/28] wip --- packages/database/src/Mappers/SelectModelMapper.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/database/src/Mappers/SelectModelMapper.php b/packages/database/src/Mappers/SelectModelMapper.php index adbe34be1..237273224 100644 --- a/packages/database/src/Mappers/SelectModelMapper.php +++ b/packages/database/src/Mappers/SelectModelMapper.php @@ -73,13 +73,13 @@ private function normalizeFields(ModelInspector $model, array $rows): array continue; } - $hasManyRelations[$mainField] ??= $hasMany; + $hasManyRelations[$hasMany->property->getName()] ??= $hasMany; $data[$hasMany->property->getName()][$hasManyId][str_replace($mainField . '.', '', $field)] = $value; } } } -ld($data, $hasManyRelations); + foreach ($hasManyRelations as $name => $hasMany) { $data[$name] = array_values($data[$name]); } From da89bee25dcaa72b5c9599aa4c65bc3cc5d314d3 Mon Sep 17 00:00:00 2001 From: brendt Date: Thu, 15 May 2025 14:31:57 +0200 Subject: [PATCH 18/28] wip --- packages/database/src/BelongsTo.php | 4 ++ .../QueryBuilders/SelectQueryBuilder.php | 4 +- packages/database/src/HasMany.php | 4 ++ packages/database/src/HasOne.php | 4 ++ .../src/Mappers/SelectModelMapper.php | 33 +++++++------ packages/database/src/Relation.php | 4 ++ .../Fixtures/Migrations/CreateAuthorTable.php | 14 +++--- .../Migrations/CreatePublishersTable.php | 29 ++++++++++++ .../Fixtures/Modules/Books/Models/Author.php | 1 + .../Modules/Books/Models/Publisher.php | 14 ++++++ tests/Integration/Auth/UserModelTest.php | 1 + .../Mappers/SelectModelMapperTest.php | 46 +++++++++++++++++++ tests/Integration/ORM/IsDatabaseModelTest.php | 2 + 13 files changed, 135 insertions(+), 25 deletions(-) create mode 100644 tests/Fixtures/Migrations/CreatePublishersTable.php create mode 100644 tests/Fixtures/Modules/Books/Models/Publisher.php diff --git a/packages/database/src/BelongsTo.php b/packages/database/src/BelongsTo.php index 8eda16e63..099d07757 100644 --- a/packages/database/src/BelongsTo.php +++ b/packages/database/src/BelongsTo.php @@ -18,6 +18,10 @@ final class BelongsTo implements Relation { public PropertyReflector $property; + public string $name { + get => $this->property->getName(); + } + private ?string $parent = null; public function __construct( diff --git a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php index 1b42aae53..46dec2628 100644 --- a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php @@ -51,8 +51,8 @@ public function __construct(string|object $model, ?ImmutableArray $fields = null $this->select = new SelectStatement( table: $this->model->getTableDefinition(), fields: $fields ?? $this->model - ->getSelectFields() - ->map(fn (string $fieldName) => new FieldStatement("{$this->model->getTableName()}.{$fieldName}")->withAlias()), + ->getSelectFields() + ->map(fn (string $fieldName) => new FieldStatement("{$this->model->getTableName()}.{$fieldName}")->withAlias()), ); } diff --git a/packages/database/src/HasMany.php b/packages/database/src/HasMany.php index e123844f6..e3d50a585 100644 --- a/packages/database/src/HasMany.php +++ b/packages/database/src/HasMany.php @@ -18,6 +18,10 @@ final class HasMany implements Relation { public PropertyReflector $property; + public string $name { + get => $this->property->getName(); + } + private ?string $parent = null; public function __construct( diff --git a/packages/database/src/HasOne.php b/packages/database/src/HasOne.php index 5c7890896..68f2f1b1d 100644 --- a/packages/database/src/HasOne.php +++ b/packages/database/src/HasOne.php @@ -18,6 +18,10 @@ final class HasOne implements Relation { public PropertyReflector $property; + public string $name { + get => $this->property->getName(); + } + private ?string $parent = null; public function __construct( diff --git a/packages/database/src/Mappers/SelectModelMapper.php b/packages/database/src/Mappers/SelectModelMapper.php index 237273224..43bb5001f 100644 --- a/packages/database/src/Mappers/SelectModelMapper.php +++ b/packages/database/src/Mappers/SelectModelMapper.php @@ -2,7 +2,10 @@ namespace Tempest\Database\Mappers; +use Tempest\Database\BelongsTo; use Tempest\Database\Builder\ModelInspector; +use Tempest\Database\HasMany; +use Tempest\Database\HasOne; use Tempest\Discovery\SkipDiscovery; use Tempest\Mapper\Mapper; @@ -43,39 +46,39 @@ private function normalizeFields(ModelInspector $model, array $rows): array foreach ($rows as $row) { foreach ($row as $field => $value) { - $mainField = explode('.', $field)[0]; + $parts = explode('.', $field); + + $mainField = $parts[0]; // Main fields if ($mainField === $mainTable) { - $data[substr($field, strlen($mainTable) + 1)] = $value; + $data[$parts[1]] = $value; continue; } - // BelongsTo - if ($belongsTo = $model->getBelongsTo($mainField)) { - $data[$belongsTo->property->getName()][str_replace($mainField . '.', '', $field)] = $value; - continue; - } + $relation = $model->getRelation($parts[0]); + + // IF count > 2 - // HasOne - if ($hasOne = $model->getHasOne($mainField)) { - $data[$hasOne->property->getName()][str_replace($mainField . '.', '', $field)] = $value; + // BelongsTo + if ($relation instanceof BelongsTo || $relation instanceof HasOne) { + $data[$relation->name][$parts[1]] = $value; continue; } // HasMany - if ($hasMany = $model->getHasMany($mainField)) { - $hasManyId = $row[$hasMany->idField()]; + if ($relation instanceof HasMany) { + $hasManyId = $row[$relation->idField()]; if ($hasManyId === null) { // Empty has many relations are initialized it with an empty array - $data[$hasMany->property->getName()] ??= []; + $data[$relation->name] ??= []; continue; } - $hasManyRelations[$hasMany->property->getName()] ??= $hasMany; + $hasManyRelations[$relation->name] ??= $relation; - $data[$hasMany->property->getName()][$hasManyId][str_replace($mainField . '.', '', $field)] = $value; + $data[$relation->name][$hasManyId][str_replace($mainField . '.', '', $field)] = $value; } } } diff --git a/packages/database/src/Relation.php b/packages/database/src/Relation.php index 5d1a635b5..c53139f0b 100644 --- a/packages/database/src/Relation.php +++ b/packages/database/src/Relation.php @@ -8,6 +8,10 @@ interface Relation extends PropertyAttribute { + public string $name { + get; + } + public function setParent(string $name): self; public function getSelectFields(): ImmutableArray; diff --git a/tests/Fixtures/Migrations/CreateAuthorTable.php b/tests/Fixtures/Migrations/CreateAuthorTable.php index 8fd287ac7..092daee54 100644 --- a/tests/Fixtures/Migrations/CreateAuthorTable.php +++ b/tests/Fixtures/Migrations/CreateAuthorTable.php @@ -6,6 +6,7 @@ use Tempest\Database\DatabaseMigration; use Tempest\Database\QueryStatement; +use Tempest\Database\QueryStatements\BelongsToStatement; use Tempest\Database\QueryStatements\CreateTableStatement; use Tempest\Database\QueryStatements\DropTableStatement; use Tempest\Database\QueryStatements\PrimaryKeyStatement; @@ -18,14 +19,11 @@ final class CreateAuthorTable implements DatabaseMigration public function up(): QueryStatement { - return new CreateTableStatement( - 'authors', - [ - new PrimaryKeyStatement(), - new TextStatement('name'), - new TextStatement('type', nullable: true), - ], - ); + return CreateTableStatement::forModel(Author::class) + ->primary() + ->text('name') + ->text('type', nullable: true) + ->belongsTo('authors.publisher_id', 'publishers.id', nullable: true); } public function down(): QueryStatement diff --git a/tests/Fixtures/Migrations/CreatePublishersTable.php b/tests/Fixtures/Migrations/CreatePublishersTable.php new file mode 100644 index 000000000..3fa1d360f --- /dev/null +++ b/tests/Fixtures/Migrations/CreatePublishersTable.php @@ -0,0 +1,29 @@ +primary() + ->text('name') + ->text('description'); + } + + public function down(): QueryStatement + { + return DropTableStatement::forModel(Publisher::class); + } +} diff --git a/tests/Fixtures/Modules/Books/Models/Author.php b/tests/Fixtures/Modules/Books/Models/Author.php index 14a1047ed..771b3554f 100644 --- a/tests/Fixtures/Modules/Books/Models/Author.php +++ b/tests/Fixtures/Modules/Books/Models/Author.php @@ -17,5 +17,6 @@ public function __construct( /** @var \Tests\Tempest\Fixtures\Modules\Books\Models\Book[] */ public array $books = [], + public ?Publisher $publisher = null, ) {} } diff --git a/tests/Fixtures/Modules/Books/Models/Publisher.php b/tests/Fixtures/Modules/Books/Models/Publisher.php new file mode 100644 index 000000000..f3f89b726 --- /dev/null +++ b/tests/Fixtures/Modules/Books/Models/Publisher.php @@ -0,0 +1,14 @@ +assertSame('lotr-1', $book->isbn->value); } + public function test_has_many_map(): void + { + $data = [ + [ + 'books.id' => 1, + 'books.title' => 'LOTR', + 'chapters.id' => 1, + 'chapters.title' => 'LOTR 1.1', + ], + [ + 'books.id' => 1, + 'chapters.id' => 2, + 'chapters.title' => 'LOTR 1.2', + ], + [ + 'books.id' => 1, + 'chapters.id' => 3, + 'chapters.title' => 'LOTR 1.3', + ], + ]; + + $books = map($data)->with(SelectModelMapper::class)->to(Book::class); + $this->assertCount(3, $books[0]->chapters); + $this->assertSame('LOTR 1.1', $books[0]->chapters[0]->title); + $this->assertSame('LOTR 1.2', $books[0]->chapters[1]->title); + $this->assertSame('LOTR 1.3', $books[0]->chapters[2]->title); + } + + public function test_deeply_nested_map(): void + { + $data = [ + [ + 'books.id' => 1, + 'books.title' => 'LOTR 1', + 'authors.name' => 'Tolkien', + 'authors.publishers.id' => 2, + 'authors.publishers.name' => 'Houghton Mifflin', + 'authors.publishers.description' => 'Hello!', + ], + ]; + + $books = map($data)->with(SelectModelMapper::class)->to(Book::class); + + $this->assertSame('Houghton Mifflin', $books[0]->author->publisher->name); + } + private function data(): array { return [ diff --git a/tests/Integration/ORM/IsDatabaseModelTest.php b/tests/Integration/ORM/IsDatabaseModelTest.php index 204fbe336..79fdef2f8 100644 --- a/tests/Integration/ORM/IsDatabaseModelTest.php +++ b/tests/Integration/ORM/IsDatabaseModelTest.php @@ -16,6 +16,7 @@ use Tempest\Validation\Exceptions\ValidationException; use Tests\Tempest\Fixtures\Migrations\CreateAuthorTable; use Tests\Tempest\Fixtures\Migrations\CreateBookTable; +use Tests\Tempest\Fixtures\Migrations\CreatePublishersTable; use Tests\Tempest\Fixtures\Models\A; use Tests\Tempest\Fixtures\Models\AWithEager; use Tests\Tempest\Fixtures\Models\AWithLazy; @@ -107,6 +108,7 @@ public function test_complex_query(): void { $this->migrate( CreateMigrationsTable::class, + CreatePublishersTable::class, CreateAuthorTable::class, CreateBookTable::class, ); From 881e58015aa068a6b705f3416e838dcdf55479da Mon Sep 17 00:00:00 2001 From: brendt Date: Thu, 15 May 2025 14:44:00 +0200 Subject: [PATCH 19/28] wip --- .../src/Mappers/SelectModelMapper.php | 94 ++++++++++++------- .../Builder/InsertQueryBuilderTest.php | 8 +- .../Builder/SelectQueryBuilderTest.php | 8 +- 3 files changed, 70 insertions(+), 40 deletions(-) diff --git a/packages/database/src/Mappers/SelectModelMapper.php b/packages/database/src/Mappers/SelectModelMapper.php index 43bb5001f..b0ddb530c 100644 --- a/packages/database/src/Mappers/SelectModelMapper.php +++ b/packages/database/src/Mappers/SelectModelMapper.php @@ -40,51 +40,81 @@ private function normalizeFields(ModelInspector $model, array $rows): array { $data = []; - $mainTable = $model->getTableDefinition()->name; - - $hasManyRelations = []; +// $hasManyRelations = []; foreach ($rows as $row) { - foreach ($row as $field => $value) { - $parts = explode('.', $field); + $row = $this->normalizeRow($model, $row); + + foreach ($row as $key => $value) { + if (is_array($value)) { + $data[$key] ??= []; + $data[$key] = [...$data[$key], ...$value]; + } else { + $data[$key] = $value; + } + } + } - $mainField = $parts[0]; +// foreach ($hasManyRelations as $name => $hasMany) { +// $data[$name] = array_values($data[$name]); +// } - // Main fields - if ($mainField === $mainTable) { - $data[$parts[1]] = $value; - continue; - } + return $data; + } - $relation = $model->getRelation($parts[0]); + public function normalizeRow(ModelInspector $model, array $row): array + { + $mainTable = $model->getTableName(); - // IF count > 2 + $data = []; - // BelongsTo - if ($relation instanceof BelongsTo || $relation instanceof HasOne) { - $data[$relation->name][$parts[1]] = $value; - continue; - } + foreach ($row as $field => $value) { + $parts = explode('.', $field); - // HasMany - if ($relation instanceof HasMany) { - $hasManyId = $row[$relation->idField()]; + $mainField = $parts[0]; - if ($hasManyId === null) { - // Empty has many relations are initialized it with an empty array - $data[$relation->name] ??= []; - continue; - } + // Main fields + if ($mainField === $mainTable) { + $data[$parts[1]] = $value; + continue; + } - $hasManyRelations[$relation->name] ??= $relation; + $relation = $model->getRelation($parts[0]); - $data[$relation->name][$hasManyId][str_replace($mainField . '.', '', $field)] = $value; - } + // Nested relations + if (count($parts) > 2) { + $subRelation = model($relation)->getRelation($parts[1]); + + $data[$relation->name][$subRelation->name] ??= []; + + $data[$relation->name][$subRelation->name] = [ + ...$data[$relation->name][$subRelation->name], + ...$this->normalizeRow(model($subRelation), [ + implode('.', array_slice($parts, 1)) => $value, + ]), + ]; + + continue; } - } - foreach ($hasManyRelations as $name => $hasMany) { - $data[$name] = array_values($data[$name]); + // BelongsTo + if ($relation instanceof BelongsTo || $relation instanceof HasOne) { + $data[$relation->name][$parts[1]] = $value; + continue; + } + + // HasMany + if ($relation instanceof HasMany) { + $hasManyId = $row[$relation->idField()]; + + if ($hasManyId === null) { + // Empty has many relations are initialized it with an empty array + $data[$relation->name] ??= []; + continue; + } + + $data[$relation->name][$hasManyId][$parts[1]] = $value; + } } return $data; diff --git a/tests/Integration/Database/Builder/InsertQueryBuilderTest.php b/tests/Integration/Database/Builder/InsertQueryBuilderTest.php index 78aa87dd6..1f2d45bc3 100644 --- a/tests/Integration/Database/Builder/InsertQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/InsertQueryBuilderTest.php @@ -79,17 +79,17 @@ public function test_insert_on_model_table(): void $query = query(Author::class) ->insert( $author, - ['name' => 'other name', 'type' => AuthorType::B->value], + ['name' => 'other name', 'type' => AuthorType::B->value, 'publisher_id' => null], ) ->build(); $expected = <<assertSame($expected, $query->toSql()); - $this->assertSame(['brent', 'a', 'other name', 'b'], $query->bindings); + $this->assertSame(['brent', 'a', null, 'other name', 'b', null], $query->bindings); } public function test_insert_on_model_table_with_new_relation(): void diff --git a/tests/Integration/Database/Builder/SelectQueryBuilderTest.php b/tests/Integration/Database/Builder/SelectQueryBuilderTest.php index 263e0c689..9c042ebe3 100644 --- a/tests/Integration/Database/Builder/SelectQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/SelectQueryBuilderTest.php @@ -70,7 +70,7 @@ public function test_select_from_model(): void $sql = $query->toSql(); $expected = <<whereField('id', 2) ->first(); - $this->assertSame(['id' => 2, 'name' => 'Other', 'type' => null], $author); + $this->assertSame(['id' => 2, 'name' => 'Other', 'type' => null, 'publisher_id' => null], $author); } public function test_select_all_with_non_object_model(): void @@ -290,7 +290,7 @@ public function test_select_all_with_non_object_model(): void ->all(); $this->assertSame( - [['id' => 2, 'name' => 'Other', 'type' => null], ['id' => 3, 'name' => 'Another', 'type' => 'a']], + [['id' => 2, 'name' => 'Other', 'type' => null, 'publisher_id' => null], ['id' => 3, 'name' => 'Another', 'type' => 'a', 'publisher_id' => null]], $authors, ); } @@ -313,7 +313,7 @@ public function test_with_belongs_to_relation(): void ->build(); $this->assertSame(<< Date: Thu, 15 May 2025 15:30:59 +0200 Subject: [PATCH 20/28] wip --- .../database/src/Mappers/SelectModelMapper.php | 16 ++++++++++------ .../Database/Mappers/SelectModelMapperTest.php | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/packages/database/src/Mappers/SelectModelMapper.php b/packages/database/src/Mappers/SelectModelMapper.php index b0ddb530c..be6f4f7ef 100644 --- a/packages/database/src/Mappers/SelectModelMapper.php +++ b/packages/database/src/Mappers/SelectModelMapper.php @@ -87,12 +87,16 @@ public function normalizeRow(ModelInspector $model, array $row): array $data[$relation->name][$subRelation->name] ??= []; - $data[$relation->name][$subRelation->name] = [ - ...$data[$relation->name][$subRelation->name], - ...$this->normalizeRow(model($subRelation), [ - implode('.', array_slice($parts, 1)) => $value, - ]), - ]; + if ($subRelation instanceof BelongsTo || $subRelation instanceof HasOne) { + $data[$relation->name][$subRelation->name] = [ + ...$data[$relation->name][$subRelation->name], + ...$this->normalizeRow(model($subRelation), [ + implode('.', array_slice($parts, 1)) => $value, + ]), + ]; + } elseif ($subRelation instanceof HasMany) { + // TODO: deeply nested has many relations + } continue; } diff --git a/tests/Integration/Database/Mappers/SelectModelMapperTest.php b/tests/Integration/Database/Mappers/SelectModelMapperTest.php index 86a766fff..574a6db51 100644 --- a/tests/Integration/Database/Mappers/SelectModelMapperTest.php +++ b/tests/Integration/Database/Mappers/SelectModelMapperTest.php @@ -3,6 +3,7 @@ namespace Integration\Database\Mappers; use Tempest\Database\Mappers\SelectModelMapper; +use Tests\Tempest\Fixtures\Modules\Books\Models\Author; use Tests\Tempest\Fixtures\Modules\Books\Models\Book; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; @@ -79,6 +80,23 @@ public function test_deeply_nested_map(): void $this->assertSame('Houghton Mifflin', $books[0]->author->publisher->name); } + public function test_deeply_nested_has_many_map(): void + { + $data = [ + [ + 'authors.id' => 1, + 'authors.name' => 'Tolkien', + 'books.id' => 1, + 'books.title' => 'LOTR', + 'books.chapters.id' => 1, + 'books.chapters.title' => 'LOTR 1.1', + ], + ]; + + $authors = map($data)->with(SelectModelMapper::class)->to(Author::class); + ld($authors[0]->books[0]->chapters); + } + private function data(): array { return [ From f64d93d06c4d88a51fe3ad327f3212ad7153b42d Mon Sep 17 00:00:00 2001 From: brendt Date: Fri, 16 May 2025 08:17:40 +0200 Subject: [PATCH 21/28] wip --- packages/database/src/HasMany.php | 5 + .../src/Mappers/SelectModelMapper.php | 100 ++++++++---------- .../Mappers/SelectModelMapperTest.php | 11 +- 3 files changed, 60 insertions(+), 56 deletions(-) diff --git a/packages/database/src/HasMany.php b/packages/database/src/HasMany.php index e3d50a585..d8fa0003f 100644 --- a/packages/database/src/HasMany.php +++ b/packages/database/src/HasMany.php @@ -49,6 +49,11 @@ public function getSelectFields(): ImmutableArray ->withAliasPrefix($this->parent)); } + public function primaryKey(): string + { + return model($this->property->getIterableType()->asClass())->getPrimaryKey(); + } + public function idField(): string { $relationModel = model($this->property->getIterableType()->asClass()); diff --git a/packages/database/src/Mappers/SelectModelMapper.php b/packages/database/src/Mappers/SelectModelMapper.php index be6f4f7ef..6246f520a 100644 --- a/packages/database/src/Mappers/SelectModelMapper.php +++ b/packages/database/src/Mappers/SelectModelMapper.php @@ -2,6 +2,7 @@ namespace Tempest\Database\Mappers; +use Exception; use Tempest\Database\BelongsTo; use Tempest\Database\Builder\ModelInspector; use Tempest\Database\HasMany; @@ -9,6 +10,7 @@ use Tempest\Discovery\SkipDiscovery; use Tempest\Mapper\Mapper; +use Tempest\Support\Arr\MutableArray; use function Tempest\Database\model; use function Tempest\map; use function Tempest\Support\arr; @@ -38,36 +40,41 @@ public function map(mixed $from, mixed $to): array private function normalizeFields(ModelInspector $model, array $rows): array { - $data = []; - -// $hasManyRelations = []; + $data = new MutableArray(); foreach ($rows as $row) { - $row = $this->normalizeRow($model, $row); + $this->normalizeRow($model, $row, $data); + } - foreach ($row as $key => $value) { - if (is_array($value)) { - $data[$key] ??= []; - $data[$key] = [...$data[$key], ...$value]; - } else { - $data[$key] = $value; - } + return $this->values($model, $data->toArray()); + } + + private function values(ModelInspector $model, array $data): array + { + foreach ($data as $key => $value) { + $relation = $model->getRelation($key); + + if (! $relation instanceof HasMany) { + continue; } - } -// foreach ($hasManyRelations as $name => $hasMany) { -// $data[$name] = array_values($data[$name]); -// } + $mapped = []; + $relationModel = model($relation); + + foreach ($value as $item) { + $mapped[] = $this->values($relationModel, $item); + } + + $data[$key] = $mapped; + } return $data; } - public function normalizeRow(ModelInspector $model, array $row): array + public function normalizeRow(ModelInspector $model, array $row, MutableArray $data): array { $mainTable = $model->getTableName(); - $data = []; - foreach ($row as $field => $value) { $parts = explode('.', $field); @@ -75,52 +82,39 @@ public function normalizeRow(ModelInspector $model, array $row): array // Main fields if ($mainField === $mainTable) { - $data[$parts[1]] = $value; + $data->set($parts[1], $value); continue; } - $relation = $model->getRelation($parts[0]); + // Relations + $key = ''; + $originalKey = ''; + $currentModel = $model; - // Nested relations - if (count($parts) > 2) { - $subRelation = model($relation)->getRelation($parts[1]); + foreach ($parts as $part) { + $relation = $currentModel->getRelation($part); - $data[$relation->name][$subRelation->name] ??= []; + if ($relation instanceof BelongsTo || $relation instanceof HasOne) { + $key .= $relation->name . '.'; + $originalKey .= $relation->name . '.'; + } elseif ($relation instanceof HasMany) { + $id = $data->get($key . $relation->idField()) + ?? $row[$originalKey . $relation->idField()] + ?? null; - if ($subRelation instanceof BelongsTo || $subRelation instanceof HasOne) { - $data[$relation->name][$subRelation->name] = [ - ...$data[$relation->name][$subRelation->name], - ...$this->normalizeRow(model($subRelation), [ - implode('.', array_slice($parts, 1)) => $value, - ]), - ]; - } elseif ($subRelation instanceof HasMany) { - // TODO: deeply nested has many relations + $key .= $relation->name . '.' . $id . '.'; + $originalKey .= $relation->name . '.'; + } else { + $key .= $part; + break; } - continue; - } - - // BelongsTo - if ($relation instanceof BelongsTo || $relation instanceof HasOne) { - $data[$relation->name][$parts[1]] = $value; - continue; + $currentModel = model($relation); } - // HasMany - if ($relation instanceof HasMany) { - $hasManyId = $row[$relation->idField()]; - - if ($hasManyId === null) { - // Empty has many relations are initialized it with an empty array - $data[$relation->name] ??= []; - continue; - } - - $data[$relation->name][$hasManyId][$parts[1]] = $value; - } + $data->set($key, $value); } - return $data; + return $data->toArray(); } } diff --git a/tests/Integration/Database/Mappers/SelectModelMapperTest.php b/tests/Integration/Database/Mappers/SelectModelMapperTest.php index 574a6db51..a055156ba 100644 --- a/tests/Integration/Database/Mappers/SelectModelMapperTest.php +++ b/tests/Integration/Database/Mappers/SelectModelMapperTest.php @@ -85,16 +85,21 @@ public function test_deeply_nested_has_many_map(): void $data = [ [ 'authors.id' => 1, - 'authors.name' => 'Tolkien', 'books.id' => 1, - 'books.title' => 'LOTR', 'books.chapters.id' => 1, 'books.chapters.title' => 'LOTR 1.1', ], + [ + 'authors.id' => 1, + 'books.id' => 1, + 'books.chapters.id' => 2, + 'books.chapters.title' => 'LOTR 1.2', + ], ]; $authors = map($data)->with(SelectModelMapper::class)->to(Author::class); - ld($authors[0]->books[0]->chapters); + + $this->assertCount(2, $authors[0]->books[0]->chapters); } private function data(): array From ea70766e35949135f8eeac4cf68f85bc3eb28a99 Mon Sep 17 00:00:00 2001 From: brendt Date: Fri, 16 May 2025 10:11:33 +0200 Subject: [PATCH 22/28] wip --- packages/database/src/BelongsTo.php | 12 ++--- .../database/src/Builder/ModelInspector.php | 4 +- packages/database/src/HasMany.php | 6 +-- packages/database/src/HasOne.php | 6 +-- .../src/Mappers/SelectModelMapper.php | 22 ++++++--- .../src/QueryStatements/FieldStatement.php | 17 ++++--- tests/Fixtures/Modules/Books/Models/Isbn.php | 4 ++ .../Builder/SelectQueryBuilderTest.php | 2 +- tests/Integration/Database/HasManyTest.php | 2 +- tests/Integration/ORM/IsDatabaseModelTest.php | 45 +++++++++---------- tests/Integration/ORM/Models/ThroughModel.php | 2 + 11 files changed, 73 insertions(+), 49 deletions(-) diff --git a/packages/database/src/BelongsTo.php b/packages/database/src/BelongsTo.php index 099d07757..bbf7efb0a 100644 --- a/packages/database/src/BelongsTo.php +++ b/packages/database/src/BelongsTo.php @@ -57,11 +57,13 @@ public function getSelectFields(): ImmutableArray return $relationModel ->getSelectFields() - ->map(fn ($field) => new FieldStatement( - $relationModel->getTableName() . '.' . $field, - ) - ->withAlias() - ->withAliasPrefix($this->parent)); + ->map(function ($field) use ($relationModel) { + return new FieldStatement( + $relationModel->getTableName() . '.' . $field, + )->withAlias( + sprintf('%s.%s', $this->property->getName(), $field) + )->withAliasPrefix($this->parent); + }); } public function getJoinStatement(): JoinStatement diff --git a/packages/database/src/Builder/ModelInspector.php b/packages/database/src/Builder/ModelInspector.php index 3709d706d..725d584ba 100644 --- a/packages/database/src/Builder/ModelInspector.php +++ b/packages/database/src/Builder/ModelInspector.php @@ -260,10 +260,10 @@ public function resolveRelations(string $relationString, string $parent = ''): a $newParent = ltrim(sprintf( '%s.%s', $parent, - $relationModel->getTableName(), + $currentRelationName, ), '.'); - $relations = [$relationModel->getTableName() => $currentRelation]; + $relations = [$currentRelationName => $currentRelation]; return [...$relations, ...$relationModel->resolveRelations($newRelationString, $newParent)]; } diff --git a/packages/database/src/HasMany.php b/packages/database/src/HasMany.php index d8fa0003f..f6d3624ff 100644 --- a/packages/database/src/HasMany.php +++ b/packages/database/src/HasMany.php @@ -44,9 +44,9 @@ public function getSelectFields(): ImmutableArray ->getSelectFields() ->map(fn ($field) => new FieldStatement( $relationModel->getTableName() . '.' . $field, - ) - ->withAlias() - ->withAliasPrefix($this->parent)); + )->withAlias( + sprintf('%s.%s', $this->property->getName(), $field), + )->withAliasPrefix($this->parent)); } public function primaryKey(): string diff --git a/packages/database/src/HasOne.php b/packages/database/src/HasOne.php index 68f2f1b1d..b907c0c7e 100644 --- a/packages/database/src/HasOne.php +++ b/packages/database/src/HasOne.php @@ -44,9 +44,9 @@ public function getSelectFields(): ImmutableArray ->getSelectFields() ->map(fn ($field) => new FieldStatement( $relationModel->getTableName() . '.' . $field, - ) - ->withAlias() - ->withAliasPrefix($this->parent)); + )->withAlias( + sprintf('%s.%s', $this->property->getName(), $field) + )->withAliasPrefix($this->parent)); } public function getJoinStatement(): JoinStatement diff --git a/packages/database/src/Mappers/SelectModelMapper.php b/packages/database/src/Mappers/SelectModelMapper.php index 6246f520a..227fe1796 100644 --- a/packages/database/src/Mappers/SelectModelMapper.php +++ b/packages/database/src/Mappers/SelectModelMapper.php @@ -33,9 +33,10 @@ public function map(mixed $from, mixed $to): array ->groupBy(function (array $data) use ($idField) { return $data[$idField]; }) - ->map(fn (array $rows) => $this->normalizeFields($model, $rows)); + ->map(fn (array $rows) => $this->normalizeFields($model, $rows)) + ->values(); - return map($parsed->values()->toArray())->collection()->to($to); + return map($parsed->toArray())->collection()->to($to); } private function normalizeFields(ModelInspector $model, array $rows): array @@ -98,12 +99,21 @@ public function normalizeRow(ModelInspector $model, array $row, MutableArray $da $key .= $relation->name . '.'; $originalKey .= $relation->name . '.'; } elseif ($relation instanceof HasMany) { - $id = $data->get($key . $relation->idField()) + $hasManyId = $data->get($key . $relation->idField()) ?? $row[$originalKey . $relation->idField()] ?? null; - $key .= $relation->name . '.' . $id . '.'; $originalKey .= $relation->name . '.'; + + if (! $data->has(trim($originalKey, '.'))) { + $data->set(trim($originalKey, '.'), []); + } + + if ($hasManyId === null) { + break; + } + + $key .= $relation->name . '.' . $hasManyId . '.'; } else { $key .= $part; break; @@ -112,7 +122,9 @@ public function normalizeRow(ModelInspector $model, array $row, MutableArray $da $currentModel = model($relation); } - $data->set($key, $value); + if ($key) { + $data->set($key, $value); + } } return $data->toArray(); diff --git a/packages/database/src/QueryStatements/FieldStatement.php b/packages/database/src/QueryStatements/FieldStatement.php index c47888afa..e13a22ca4 100644 --- a/packages/database/src/QueryStatements/FieldStatement.php +++ b/packages/database/src/QueryStatements/FieldStatement.php @@ -10,7 +10,7 @@ final class FieldStatement implements QueryStatement { - private bool $withAlias = false; + private null|bool|string $alias = null; private ?string $aliasPrefix = null; public function __construct( @@ -25,13 +25,20 @@ public function compile(DatabaseDialect $dialect): string if (count($parts) === 1) { $alias = null; + $aliasPrefix = $this->aliasPrefix ? "{$this->aliasPrefix}." : ''; - if ($this->withAlias) { + if ($this->alias === true) { $alias = sprintf( '`%s%s`', - $this->aliasPrefix ? "{$this->aliasPrefix}." : '', + $aliasPrefix, str_replace('`', '', $field), ); + } elseif($this->alias) { + $alias = sprintf( + '`%s%s`', + $aliasPrefix, + $this->alias, + ); } } else { $alias = $parts[1]; @@ -61,9 +68,9 @@ public function withAliasPrefix(?string $prefix = null): self return $this; } - public function withAlias(): self + public function withAlias(bool|string $alias = true): self { - $this->withAlias = true; + $this->alias = $alias; return $this; } diff --git a/tests/Fixtures/Modules/Books/Models/Isbn.php b/tests/Fixtures/Modules/Books/Models/Isbn.php index 21fff2cfc..f54d76a7c 100644 --- a/tests/Fixtures/Modules/Books/Models/Isbn.php +++ b/tests/Fixtures/Modules/Books/Models/Isbn.php @@ -2,8 +2,12 @@ namespace Tests\Tempest\Fixtures\Modules\Books\Models; +use Tempest\Database\IsDatabaseModel; + final class Isbn { + use IsDatabaseModel; + public string $value; public Book $book; diff --git a/tests/Integration/Database/Builder/SelectQueryBuilderTest.php b/tests/Integration/Database/Builder/SelectQueryBuilderTest.php index 9c042ebe3..c5a0d8ee9 100644 --- a/tests/Integration/Database/Builder/SelectQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/SelectQueryBuilderTest.php @@ -313,7 +313,7 @@ public function test_with_belongs_to_relation(): void ->build(); $this->assertSame(<<getRelation('owners')->setParent('parent'); $this->assertSame( - 'owner.relation_id AS `parent.owner.relation_id`', + 'owner.relation_id AS `parent.owners.relation_id`', $relation->getSelectFields()[0]->compile(DatabaseDialect::SQLITE), ); } diff --git a/tests/Integration/ORM/IsDatabaseModelTest.php b/tests/Integration/ORM/IsDatabaseModelTest.php index 79fdef2f8..49d39667d 100644 --- a/tests/Integration/ORM/IsDatabaseModelTest.php +++ b/tests/Integration/ORM/IsDatabaseModelTest.php @@ -16,6 +16,8 @@ use Tempest\Validation\Exceptions\ValidationException; use Tests\Tempest\Fixtures\Migrations\CreateAuthorTable; use Tests\Tempest\Fixtures\Migrations\CreateBookTable; +use Tests\Tempest\Fixtures\Migrations\CreateChapterTable; +use Tests\Tempest\Fixtures\Migrations\CreateIsbnTable; use Tests\Tempest\Fixtures\Migrations\CreatePublishersTable; use Tests\Tempest\Fixtures\Models\A; use Tests\Tempest\Fixtures\Models\AWithEager; @@ -27,6 +29,7 @@ use Tests\Tempest\Fixtures\Modules\Books\Models\Author; use Tests\Tempest\Fixtures\Modules\Books\Models\AuthorType; use Tests\Tempest\Fixtures\Modules\Books\Models\Book; +use Tests\Tempest\Fixtures\Modules\Books\Models\Isbn; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; use Tests\Tempest\Integration\ORM\Migrations\CreateATable; use Tests\Tempest\Integration\ORM\Migrations\CreateBTable; @@ -296,41 +299,36 @@ public function test_empty_has_many_relation(): void { $this->migrate( CreateMigrationsTable::class, - CreateHasManyParentTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + CreateChapterTable::class, CreateHasManyChildTable::class, - CreateHasManyThroughTable::class, ); - $parent = new ParentModel(name: 'parent')->save(); - - $parent = ParentModel::select()->with('through.child')->get($parent->id); - - $this->assertInstanceOf(ParentModel::class, $parent); - $this->assertEmpty($parent->through); + Book::new(title: 'Timeline Taxi')->save(); + $book = Book::select()->with('chapters')->first(); + $this->assertEmpty($book->chapters); } public function test_has_one_relation(): void { $this->migrate( CreateMigrationsTable::class, - CreateHasManyParentTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + CreateChapterTable::class, CreateHasManyChildTable::class, - CreateHasManyThroughTable::class, + CreateIsbnTable::class, ); - $parent = new ParentModel(name: 'parent')->save(); - $childA = new ChildModel(name: 'A')->save(); - $childB = new ChildModel(name: 'B')->save(); - - new ThroughModel(parent: $parent, child: $childA, child2: $childB)->save(); + $book = Book::new(title: 'Timeline Taxi')->save(); + $isbn = Isbn::new(value: 'tt-1', book: $book)->save(); - $child = ChildModel::select()->with('through.parent')->get($childA->id); + $isbn = Isbn::select()->with('book')->get($isbn->id); - $this->assertSame('parent', $child->through->parent->name); - - $child2 = ChildModel::select()->with('through2.parent')->get($childB->id); - - $this->assertSame('parent', $child2->through2->parent->name); + $this->assertSame('Timeline Taxi', $isbn->book->title); } public function test_invalid_has_one_relation(): void @@ -345,15 +343,14 @@ public function test_invalid_has_one_relation(): void $parent = new ParentModel(name: 'parent')->save(); $childA = new ChildModel(name: 'A')->save(); - $childB = new ChildModel(name: 'B')->save(); new ThroughModel(parent: $parent, child: $childA, child2: $childB)->save(); $child = ChildModel::get($childA->id, ['through.parent']); - $child2 = ChildModel::get($childB->id, ['through2.parent']); - $this->assertSame('parent', $child->through->parent->name); + + $child2 = ChildModel::select()->with('through2.parent')->get($childB->id); $this->assertSame('parent', $child2->through2->parent->name); } diff --git a/tests/Integration/ORM/Models/ThroughModel.php b/tests/Integration/ORM/Models/ThroughModel.php index e65162bc6..5e0035a42 100644 --- a/tests/Integration/ORM/Models/ThroughModel.php +++ b/tests/Integration/ORM/Models/ThroughModel.php @@ -4,6 +4,7 @@ namespace Tests\Tempest\Integration\ORM\Models; +use Tempest\Database\BelongsTo; use Tempest\Database\IsDatabaseModel; use Tempest\Database\Table; @@ -15,6 +16,7 @@ final class ThroughModel public function __construct( public ParentModel $parent, public ChildModel $child, + #[BelongsTo(ownerJoin: 'child2_id')] public ?ChildModel $child2 = null, ) {} } From 01740206ac260f91ff27d569152e0c1e77ebc4a2 Mon Sep 17 00:00:00 2001 From: brendt Date: Fri, 16 May 2025 10:31:01 +0200 Subject: [PATCH 23/28] wip --- packages/database/src/BelongsTo.php | 8 +++++--- packages/database/src/HasMany.php | 10 ++++++---- packages/database/src/HasOne.php | 8 +++++--- .../src/Mappers/SelectModelMapper.php | 8 +++----- .../src/QueryStatements/FieldStatement.php | 2 +- .../QueryStatements/SelectStatementTest.php | 12 +++++------ .../Mappers/SelectModelMapperTest.php | 20 +++++++++++++++++++ 7 files changed, 46 insertions(+), 22 deletions(-) diff --git a/packages/database/src/BelongsTo.php b/packages/database/src/BelongsTo.php index bbf7efb0a..8722ad539 100644 --- a/packages/database/src/BelongsTo.php +++ b/packages/database/src/BelongsTo.php @@ -60,9 +60,11 @@ public function getSelectFields(): ImmutableArray ->map(function ($field) use ($relationModel) { return new FieldStatement( $relationModel->getTableName() . '.' . $field, - )->withAlias( - sprintf('%s.%s', $this->property->getName(), $field) - )->withAliasPrefix($this->parent); + ) + ->withAlias( + sprintf('%s.%s', $this->property->getName(), $field), + ) + ->withAliasPrefix($this->parent); }); } diff --git a/packages/database/src/HasMany.php b/packages/database/src/HasMany.php index f6d3624ff..b04fe96ad 100644 --- a/packages/database/src/HasMany.php +++ b/packages/database/src/HasMany.php @@ -44,9 +44,11 @@ public function getSelectFields(): ImmutableArray ->getSelectFields() ->map(fn ($field) => new FieldStatement( $relationModel->getTableName() . '.' . $field, - )->withAlias( - sprintf('%s.%s', $this->property->getName(), $field), - )->withAliasPrefix($this->parent)); + ) + ->withAlias( + sprintf('%s.%s', $this->property->getName(), $field), + ) + ->withAliasPrefix($this->parent)); } public function primaryKey(): string @@ -60,7 +62,7 @@ public function idField(): string return sprintf( '%s.%s', - $relationModel->getTableName(), + $this->property->getName(), $relationModel->getPrimaryKey(), ); } diff --git a/packages/database/src/HasOne.php b/packages/database/src/HasOne.php index b907c0c7e..878452587 100644 --- a/packages/database/src/HasOne.php +++ b/packages/database/src/HasOne.php @@ -44,9 +44,11 @@ public function getSelectFields(): ImmutableArray ->getSelectFields() ->map(fn ($field) => new FieldStatement( $relationModel->getTableName() . '.' . $field, - )->withAlias( - sprintf('%s.%s', $this->property->getName(), $field) - )->withAliasPrefix($this->parent)); + ) + ->withAlias( + sprintf('%s.%s', $this->property->getName(), $field), + ) + ->withAliasPrefix($this->parent)); } public function getJoinStatement(): JoinStatement diff --git a/packages/database/src/Mappers/SelectModelMapper.php b/packages/database/src/Mappers/SelectModelMapper.php index 227fe1796..62234310d 100644 --- a/packages/database/src/Mappers/SelectModelMapper.php +++ b/packages/database/src/Mappers/SelectModelMapper.php @@ -9,8 +9,8 @@ use Tempest\Database\HasOne; use Tempest\Discovery\SkipDiscovery; use Tempest\Mapper\Mapper; - use Tempest\Support\Arr\MutableArray; + use function Tempest\Database\model; use function Tempest\map; use function Tempest\Support\arr; @@ -55,7 +55,7 @@ private function values(ModelInspector $model, array $data): array foreach ($data as $key => $value) { $relation = $model->getRelation($key); - if (! $relation instanceof HasMany) { + if (! ($relation instanceof HasMany)) { continue; } @@ -99,9 +99,7 @@ public function normalizeRow(ModelInspector $model, array $row, MutableArray $da $key .= $relation->name . '.'; $originalKey .= $relation->name . '.'; } elseif ($relation instanceof HasMany) { - $hasManyId = $data->get($key . $relation->idField()) - ?? $row[$originalKey . $relation->idField()] - ?? null; + $hasManyId = $data->get($key . $relation->idField()) ?? $row[$originalKey . $relation->idField()] ?? null; $originalKey .= $relation->name . '.'; diff --git a/packages/database/src/QueryStatements/FieldStatement.php b/packages/database/src/QueryStatements/FieldStatement.php index e13a22ca4..8ee8d6c29 100644 --- a/packages/database/src/QueryStatements/FieldStatement.php +++ b/packages/database/src/QueryStatements/FieldStatement.php @@ -33,7 +33,7 @@ public function compile(DatabaseDialect $dialect): string $aliasPrefix, str_replace('`', '', $field), ); - } elseif($this->alias) { + } elseif ($this->alias) { $alias = sprintf( '`%s%s`', $aliasPrefix, diff --git a/packages/database/tests/QueryStatements/SelectStatementTest.php b/packages/database/tests/QueryStatements/SelectStatementTest.php index 05005910e..d4f2c6a1c 100644 --- a/packages/database/tests/QueryStatements/SelectStatementTest.php +++ b/packages/database/tests/QueryStatements/SelectStatementTest.php @@ -49,13 +49,13 @@ public function test_select(): void $this->assertSame($expectedWithBackticks, $statement->compile(DatabaseDialect::POSTGRESQL)); $expectedWithoutBackticks = <<assertCount(2, $authors[0]->books[0]->chapters); } + public function test_map_user_permissions(): void + { + $data = [ + [ + 'users.password' => '$2y$12$sH1n2N7iE6F8RNDceiAHG.WWtXU7j9f2IBwsi3zIb.QMvn0Cwxpi6', + 'users.name' => 'Brent', + 'users.email' => 'brendt@stitcher.io', + 'users.id' => 1, + 'userPermissions.user_id' => 1, + 'userPermissions.permission_id' => 1, + 'userPermissions.id' => 1, + ], + ]; + + $users = map($data)->with(SelectModelMapper::class)->to(User::class); + + $this->assertCount(1, $users[0]->userPermissions); + } + private function data(): array { return [ From e9fc218b8592efb40ceca9e9481493a05e2b1029 Mon Sep 17 00:00:00 2001 From: brendt Date: Fri, 16 May 2025 10:32:35 +0200 Subject: [PATCH 24/28] wip --- tests/Integration/Database/Mappers/SelectModelMapperTest.php | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/Integration/Database/Mappers/SelectModelMapperTest.php b/tests/Integration/Database/Mappers/SelectModelMapperTest.php index 823eb5bd4..f3dbd39cb 100644 --- a/tests/Integration/Database/Mappers/SelectModelMapperTest.php +++ b/tests/Integration/Database/Mappers/SelectModelMapperTest.php @@ -107,7 +107,6 @@ public function test_map_user_permissions(): void { $data = [ [ - 'users.password' => '$2y$12$sH1n2N7iE6F8RNDceiAHG.WWtXU7j9f2IBwsi3zIb.QMvn0Cwxpi6', 'users.name' => 'Brent', 'users.email' => 'brendt@stitcher.io', 'users.id' => 1, From ee04fe958200f52cfa9da66759976ce071d81526 Mon Sep 17 00:00:00 2001 From: brendt Date: Fri, 16 May 2025 10:39:09 +0200 Subject: [PATCH 25/28] wip --- .../src/Mappers/QueryToModelMapper.php | 224 ------------------ 1 file changed, 224 deletions(-) delete mode 100644 packages/database/src/Mappers/QueryToModelMapper.php diff --git a/packages/database/src/Mappers/QueryToModelMapper.php b/packages/database/src/Mappers/QueryToModelMapper.php deleted file mode 100644 index a812b507a..000000000 --- a/packages/database/src/Mappers/QueryToModelMapper.php +++ /dev/null @@ -1,224 +0,0 @@ -getTableDefinition(); - - $models = []; - - foreach ($from->fetch() as $row) { - $idField = $table->name . '.id'; - - $id = $row[$idField]; - - $model = $models[$id] ?? $class->newInstanceWithoutConstructor(); - - $models[$id] = $this->parse($class, $model, $row); - } - - return $this->makeLazyCollection($models); - } - - private function parse(ClassReflector $class, object $model, array $row): object - { - foreach ($row as $key => $value) { - $keyParts = explode('.', $key); - - $propertyName = $keyParts[1]; - - $count = count($keyParts); - - // TODO: clean up and document - if ($count > 3) { - $property = $class->getProperty(rtrim($propertyName, '[]')); - - if ($property->getIterableType()?->isRelation()) { - $collection = $property->get($model, []); - $childId = $row[$keyParts[0] . '.' . $keyParts[1] . '.id']; - - if ($childId) { - $iterableType = $property->getIterableType(); - - $childModel = $collection[$childId] ?? $iterableType->asClass()->newInstanceWithoutConstructor(); - - unset($keyParts[0]); - - $collection[$childId] = $this->parse( - $iterableType->asClass(), - $childModel, - [implode('.', $keyParts) => $value], - ); - } - - $property->set($model, $collection); - } else { - $childModelType = $property->getType(); - - $childModel = $property->get($model, $childModelType->asClass()->newInstanceWithoutConstructor()); - - unset($keyParts[0]); - - $property->set($model, $this->parse( - $childModelType->asClass(), - $childModel, - [implode('.', $keyParts) => $value], - )); - } - } elseif ($count === 3) { - $childId = $row[$keyParts[0] . '.' . $keyParts[1] . '.id'] ?? null; - - if (str_contains($keyParts[1], '[]')) { - $property = $class->getProperty(rtrim($propertyName, '[]')); - - $model = $this->parseHasMany( - $property, - $model, - (string) $childId, - $keyParts[2], - $value, - ); - } else { - $property = $class->getProperty($propertyName); - - $model = $this->parseBelongsTo( - $property, - $model, - $keyParts[2], - $value, - ); - } - } else { - $property = $class->getProperty($propertyName); - - $model = $this->parseProperty($property, $model, $value); - } - } - - return $model; - } - - private function parseProperty(PropertyReflector $property, object $model, mixed $value): object - { - $caster = $this->casterFactory->forProperty($property); - - if ($value && $caster !== null) { - $value = $caster->cast($value); - } - - if ($value === null && ! $property->isNullable()) { - return $model; - } - - $property->set($model, $value); - - return $model; - } - - private function parseBelongsTo(PropertyReflector $property, object $model, string $childProperty, mixed $value): object - { - $childModel = $property->get( - $model, - $property->getType()->asClass()->newInstanceWithoutConstructor(), - ); - - $childProperty = new ClassReflector($childModel)->getProperty($childProperty); - - // TODO: must pass through the mapper - $this->parseProperty( - $childProperty, - $childModel, - $value, - ); - - $property->set($model, $childModel); - - return $model; - } - - private function parseHasMany(PropertyReflector $property, object $model, ?string $childId, string $childProperty, mixed $value): object - { - $collection = $property->get($model, []); - - if (! $childId) { - $property->set($model, $collection); - - return $model; - } - - $childModel = $collection[$childId] ?? $property->getIterableType()->asClass()->newInstanceWithoutConstructor(); - - $childProperty = new ClassReflector($childModel)->getProperty($childProperty); - - // TODO: must pass through the mapper - $this->parseProperty( - $childProperty, - $childModel, - $value, - ); - - $collection[$childId] = $childModel; - - $property->set($model, $collection); - - return $model; - } - - private function makeLazyCollection(array $models): array - { - $lazy = []; - - foreach ($models as $model) { - $lazy[] = $this->makeLazyModel($model); - } - - return $lazy; - } - - private function makeLazyModel(object $model): object - { - $classReflector = new ClassReflector($model); - - foreach ($classReflector->getPublicProperties() as $property) { - if ($property->isUninitialized($model)) { - $property->unset($model); - - continue; - } - - if ($property->getIterableType()?->isRelation()) { - foreach ($property->get($model) as $childModel) { - $this->makeLazyModel($childModel); - } - - break; - } - } - - return $model; - } -} From 25fc0330af37031657a5e33f5eafe35adb6ed06f Mon Sep 17 00:00:00 2001 From: brendt Date: Fri, 16 May 2025 10:40:39 +0200 Subject: [PATCH 26/28] wip --- .../src/Exceptions/InvalidRelation.php | 49 ------------------- 1 file changed, 49 deletions(-) delete mode 100644 packages/database/src/Exceptions/InvalidRelation.php diff --git a/packages/database/src/Exceptions/InvalidRelation.php b/packages/database/src/Exceptions/InvalidRelation.php deleted file mode 100644 index 0adfc3856..000000000 --- a/packages/database/src/Exceptions/InvalidRelation.php +++ /dev/null @@ -1,49 +0,0 @@ - Date: Fri, 16 May 2025 10:42:11 +0200 Subject: [PATCH 27/28] wip --- packages/database/src/QueryStatements/SelectStatement.php | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/database/src/QueryStatements/SelectStatement.php b/packages/database/src/QueryStatements/SelectStatement.php index 22e4791fc..366376b9f 100644 --- a/packages/database/src/QueryStatements/SelectStatement.php +++ b/packages/database/src/QueryStatements/SelectStatement.php @@ -89,13 +89,6 @@ public function compile(DatabaseDialect $dialect): string $compiled = $query->implode(PHP_EOL); - /* TODO: this should be improved. - * More specifically, \Tempest\Database\Builder\FieldDefinition should be aware of the dialect, - * or the whole ORM should be refactored to use \Tempest\Database\QueryStatements\FieldStatement*/ - // if ($dialect === DatabaseDialect::SQLITE) { - // $compiled = $compiled->replace('`', ''); - // } - return $compiled; } } From b0c913a24c0e75a5fb22f0c94e48d83ad34ee136 Mon Sep 17 00:00:00 2001 From: brendt Date: Fri, 16 May 2025 12:57:12 +0200 Subject: [PATCH 28/28] wip --- .../Fixtures/Migrations/CreateAuthorTable.php | 5 +-- tests/Fixtures/Migrations/CreateBookTable.php | 2 +- .../Migrations/CreateChapterTable.php | 2 +- tests/Fixtures/Migrations/CreateIsbnTable.php | 2 +- .../Fixtures/Modules/Books/Models/Chapter.php | 2 +- .../Builder/DeleteQueryBuilderTest.php | 3 +- .../Builder/InsertQueryBuilderTest.php | 5 ++- .../Builder/SelectQueryBuilderTest.php | 45 +++++++++++++------ .../Builder/UpdateQueryBuilderTest.php | 3 +- .../Database/GenericDatabaseTest.php | 3 +- .../GenericTransactionManagerTest.php | 7 +-- tests/Integration/Database/QueryTest.php | 3 +- tests/Integration/ORM/IsDatabaseModelTest.php | 3 ++ tests/Integration/Route/RequestTest.php | 3 ++ tests/Integration/Route/RouterTest.php | 2 + 15 files changed, 59 insertions(+), 31 deletions(-) diff --git a/tests/Fixtures/Migrations/CreateAuthorTable.php b/tests/Fixtures/Migrations/CreateAuthorTable.php index 092daee54..4b6558a9a 100644 --- a/tests/Fixtures/Migrations/CreateAuthorTable.php +++ b/tests/Fixtures/Migrations/CreateAuthorTable.php @@ -6,16 +6,13 @@ use Tempest\Database\DatabaseMigration; use Tempest\Database\QueryStatement; -use Tempest\Database\QueryStatements\BelongsToStatement; use Tempest\Database\QueryStatements\CreateTableStatement; use Tempest\Database\QueryStatements\DropTableStatement; -use Tempest\Database\QueryStatements\PrimaryKeyStatement; -use Tempest\Database\QueryStatements\TextStatement; use Tests\Tempest\Fixtures\Modules\Books\Models\Author; final class CreateAuthorTable implements DatabaseMigration { - private(set) string $name = '0000-00-00_create_authors_table'; + private(set) string $name = '0000-00-01_create_authors_table'; public function up(): QueryStatement { diff --git a/tests/Fixtures/Migrations/CreateBookTable.php b/tests/Fixtures/Migrations/CreateBookTable.php index cd54861a7..6c69d9e9d 100644 --- a/tests/Fixtures/Migrations/CreateBookTable.php +++ b/tests/Fixtures/Migrations/CreateBookTable.php @@ -12,7 +12,7 @@ final class CreateBookTable implements DatabaseMigration { - private(set) string $name = '0000-00-00_create_books_table'; + private(set) string $name = '0000-00-02_create_books_table'; public function up(): QueryStatement { diff --git a/tests/Fixtures/Migrations/CreateChapterTable.php b/tests/Fixtures/Migrations/CreateChapterTable.php index c2454aea9..051f85e69 100644 --- a/tests/Fixtures/Migrations/CreateChapterTable.php +++ b/tests/Fixtures/Migrations/CreateChapterTable.php @@ -13,7 +13,7 @@ final class CreateChapterTable implements DatabaseMigration { - private(set) string $name = '0000-00-00_create_chapters_table'; + private(set) string $name = '0000-00-03_create_chapters_table'; public function up(): QueryStatement { diff --git a/tests/Fixtures/Migrations/CreateIsbnTable.php b/tests/Fixtures/Migrations/CreateIsbnTable.php index 6dd7631b0..0dbbd4609 100644 --- a/tests/Fixtures/Migrations/CreateIsbnTable.php +++ b/tests/Fixtures/Migrations/CreateIsbnTable.php @@ -13,7 +13,7 @@ final class CreateIsbnTable implements DatabaseMigration { - private(set) string $name = '0000-00-00_create_isbns_table'; + private(set) string $name = '0000-00-04_create_isbns_table'; public function up(): QueryStatement { diff --git a/tests/Fixtures/Modules/Books/Models/Chapter.php b/tests/Fixtures/Modules/Books/Models/Chapter.php index 4c813a662..cfc28c317 100644 --- a/tests/Fixtures/Modules/Books/Models/Chapter.php +++ b/tests/Fixtures/Modules/Books/Models/Chapter.php @@ -12,7 +12,7 @@ final class Chapter public string $title; - public string $contents; + public ?string $contents; public Book $book; } diff --git a/tests/Integration/Database/Builder/DeleteQueryBuilderTest.php b/tests/Integration/Database/Builder/DeleteQueryBuilderTest.php index 37dd11d4e..3aaff3a26 100644 --- a/tests/Integration/Database/Builder/DeleteQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/DeleteQueryBuilderTest.php @@ -6,6 +6,7 @@ use Tempest\Database\Id; use Tempest\Database\Migrations\CreateMigrationsTable; use Tests\Tempest\Fixtures\Migrations\CreateAuthorTable; +use Tests\Tempest\Fixtures\Migrations\CreatePublishersTable; use Tests\Tempest\Fixtures\Modules\Books\Models\Author; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; @@ -102,7 +103,7 @@ public function test_delete_on_plain_table_with_conditions(): void public function test_delete_with_non_object_model(): void { - $this->migrate(CreateMigrationsTable::class, CreateAuthorTable::class); + $this->migrate(CreateMigrationsTable::class, CreatePublishersTable::class, CreateAuthorTable::class); query('authors')->insert( ['id' => 1, 'name' => 'Brent'], diff --git a/tests/Integration/Database/Builder/InsertQueryBuilderTest.php b/tests/Integration/Database/Builder/InsertQueryBuilderTest.php index 1f2d45bc3..73a1925a0 100644 --- a/tests/Integration/Database/Builder/InsertQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/InsertQueryBuilderTest.php @@ -10,6 +10,7 @@ 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\AuthorType; use Tests\Tempest\Fixtures\Modules\Books\Models\Book; @@ -183,7 +184,7 @@ public function test_inserting_has_one_via_parent_model_throws_exception(): void public function test_then_method(): void { - $this->migrate(CreateMigrationsTable::class, CreateAuthorTable::class, CreateBookTable::class, CreateChapterTable::class); + $this->migrate(CreateMigrationsTable::class, CreatePublishersTable::class, CreateAuthorTable::class, CreateBookTable::class, CreateChapterTable::class); $id = query(Book::class) ->insert(title: 'Timeline Taxi') @@ -208,7 +209,7 @@ public function test_then_method(): void public function test_insert_with_non_object_model(): void { - $this->migrate(CreateMigrationsTable::class, CreateAuthorTable::class); + $this->migrate(CreateMigrationsTable::class, CreatePublishersTable::class, CreateAuthorTable::class); query('authors')->insert( ['id' => 1, 'name' => 'Brent'], diff --git a/tests/Integration/Database/Builder/SelectQueryBuilderTest.php b/tests/Integration/Database/Builder/SelectQueryBuilderTest.php index c5a0d8ee9..28cc1f892 100644 --- a/tests/Integration/Database/Builder/SelectQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/SelectQueryBuilderTest.php @@ -10,6 +10,7 @@ use Tests\Tempest\Fixtures\Migrations\CreateBookTable; use Tests\Tempest\Fixtures\Migrations\CreateChapterTable; use Tests\Tempest\Fixtures\Migrations\CreateIsbnTable; +use Tests\Tempest\Fixtures\Migrations\CreatePublishersTable; use Tests\Tempest\Fixtures\Models\AWithEager; use Tests\Tempest\Fixtures\Modules\Books\Models\Author; use Tests\Tempest\Fixtures\Modules\Books\Models\AuthorType; @@ -35,17 +36,17 @@ public function test_select_query(): void $expected = << ? - OR `createdAt` > ? - ORDER BY `index` ASC + FROM chapters + WHERE title = ? + AND index <> ? + OR createdAt > ? + ORDER BY index ASC SQL; $sql = $query->toSql(); $bindings = $query->bindings; - $this->assertSame($expected, $sql); + $this->assertSameWithoutBackticks($expected, $sql); $this->assertSame(['Timeline Taxi', '1', '2025-01-01'], $bindings); } @@ -60,7 +61,7 @@ public function test_select_without_any_fields_specified(): void FROM `chapters` SQL; - $this->assertSame($expected, $sql); + $this->assertSameWithoutBackticks($expected, $sql); } public function test_select_from_model(): void @@ -74,13 +75,14 @@ public function test_select_from_model(): void FROM `authors` SQL; - $this->assertSame($expected, $sql); + $this->assertSameWithoutBackticks($expected, $sql); } public function test_where_statement(): void { $this->migrate( CreateMigrationsTable::class, + CreatePublishersTable::class, CreateAuthorTable::class, CreateBookTable::class, ); @@ -99,6 +101,7 @@ public function test_join(): void { $this->migrate( CreateMigrationsTable::class, + CreatePublishersTable::class, CreateAuthorTable::class, CreateBookTable::class, ); @@ -121,6 +124,7 @@ public function test_order_by(): void { $this->migrate( CreateMigrationsTable::class, + CreatePublishersTable::class, CreateAuthorTable::class, CreateBookTable::class, ); @@ -139,6 +143,7 @@ public function test_limit(): void { $this->migrate( CreateMigrationsTable::class, + CreatePublishersTable::class, CreateAuthorTable::class, CreateBookTable::class, ); @@ -159,6 +164,7 @@ public function test_offset(): void { $this->migrate( CreateMigrationsTable::class, + CreatePublishersTable::class, CreateAuthorTable::class, CreateBookTable::class, ); @@ -182,6 +188,7 @@ public function test_chunk(): void { $this->migrate( CreateMigrationsTable::class, + CreatePublishersTable::class, CreateAuthorTable::class, CreateBookTable::class, ); @@ -208,6 +215,7 @@ public function test_raw(): void { $this->migrate( CreateMigrationsTable::class, + CreatePublishersTable::class, CreateAuthorTable::class, CreateBookTable::class, ); @@ -253,13 +261,13 @@ public function test_select_query_with_conditions(): void $sql = $query->toSql(); $bindings = $query->bindings; - $this->assertSame($expected, $sql); + $this->assertSameWithoutBackticks($expected, $sql); $this->assertSame(['Timeline Taxi', '1', '2025-01-01'], $bindings); } public function test_select_first_with_non_object_model(): void { - $this->migrate(CreateMigrationsTable::class, CreateAuthorTable::class); + $this->migrate(CreateMigrationsTable::class, CreatePublishersTable::class, CreateAuthorTable::class); query('authors')->insert( ['id' => 1, 'name' => 'Brent'], @@ -276,7 +284,7 @@ public function test_select_first_with_non_object_model(): void public function test_select_all_with_non_object_model(): void { - $this->migrate(CreateMigrationsTable::class, CreateAuthorTable::class); + $this->migrate(CreateMigrationsTable::class, CreatePublishersTable::class, CreateAuthorTable::class); query('authors')->insert( ['id' => 1, 'name' => 'Brent', 'type' => AuthorType::B], @@ -299,7 +307,7 @@ public function test_select_includes_belongs_to(): void { $query = query(Book::class)->select(); - $this->assertSame(<<assertSameWithoutBackticks(<<build()->toSql()); @@ -312,7 +320,7 @@ public function test_with_belongs_to_relation(): void ->with('author', 'chapters', 'isbn') ->build(); - $this->assertSame(<<assertSameWithoutBackticks(<<with('b.c')->toSql(); - $this->assertSame(<<assertSameWithoutBackticks(<<migrate( CreateMigrationsTable::class, + CreatePublishersTable::class, CreateAuthorTable::class, CreateBookTable::class, CreateChapterTable::class, @@ -404,4 +413,12 @@ private function seed(): void ['title' => 'Timeline Taxi Chapter 4', 'book_id' => 4], )->execute(); } + + private function assertSameWithoutBackticks(string $expected, string $actual): void + { + $this->assertSame( + str_replace('`', '', $expected), + str_replace('`', '', $actual), + ); + } } diff --git a/tests/Integration/Database/Builder/UpdateQueryBuilderTest.php b/tests/Integration/Database/Builder/UpdateQueryBuilderTest.php index baa4059a1..40c960a85 100644 --- a/tests/Integration/Database/Builder/UpdateQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/UpdateQueryBuilderTest.php @@ -11,6 +11,7 @@ use Tempest\Database\Migrations\CreateMigrationsTable; use Tempest\Database\Query; use Tests\Tempest\Fixtures\Migrations\CreateAuthorTable; +use Tests\Tempest\Fixtures\Migrations\CreatePublishersTable; use Tests\Tempest\Fixtures\Modules\Books\Models\Author; use Tests\Tempest\Fixtures\Modules\Books\Models\AuthorType; use Tests\Tempest\Fixtures\Modules\Books\Models\Book; @@ -265,7 +266,7 @@ public function test_update_on_plain_table_with_conditions(): void public function test_update_with_non_object_model(): void { - $this->migrate(CreateMigrationsTable::class, CreateAuthorTable::class); + $this->migrate(CreateMigrationsTable::class, CreatePublishersTable::class, CreateAuthorTable::class); query('authors')->insert( ['id' => 1, 'name' => 'Brent'], diff --git a/tests/Integration/Database/GenericDatabaseTest.php b/tests/Integration/Database/GenericDatabaseTest.php index a99c1face..9b2d5e217 100644 --- a/tests/Integration/Database/GenericDatabaseTest.php +++ b/tests/Integration/Database/GenericDatabaseTest.php @@ -9,6 +9,7 @@ use Tempest\Database\Migrations\CreateMigrationsTable; use Tempest\Database\Migrations\Migration; use Tests\Tempest\Fixtures\Migrations\CreateAuthorTable; +use Tests\Tempest\Fixtures\Migrations\CreatePublishersTable; use Tests\Tempest\Fixtures\Modules\Books\Models\Author; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; @@ -33,7 +34,7 @@ public function test_execute_with_fail_works_correctly(): void { $database = $this->container->get(Database::class); - $this->migrate(CreateMigrationsTable::class, CreateAuthorTable::class); + $this->migrate(CreateMigrationsTable::class, CreatePublishersTable::class, CreateAuthorTable::class); $database->withinTransaction(function (): never { new Author(name: 'test')->save(); diff --git a/tests/Integration/Database/GenericTransactionManagerTest.php b/tests/Integration/Database/GenericTransactionManagerTest.php index 03d951f50..d2d6ad71a 100644 --- a/tests/Integration/Database/GenericTransactionManagerTest.php +++ b/tests/Integration/Database/GenericTransactionManagerTest.php @@ -8,6 +8,7 @@ use Tempest\Database\Migrations\CreateMigrationsTable; use Tempest\Database\Transactions\TransactionManager; use Tests\Tempest\Fixtures\Migrations\CreateAuthorTable; +use Tests\Tempest\Fixtures\Migrations\CreatePublishersTable; use Tests\Tempest\Fixtures\Modules\Books\Models\Author; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; @@ -18,7 +19,7 @@ final class GenericTransactionManagerTest extends FrameworkIntegrationTestCase { public function test_transaction_manager(): void { - $this->migrate(CreateMigrationsTable::class, CreateAuthorTable::class); + $this->migrate(CreateMigrationsTable::class, CreatePublishersTable::class, CreateAuthorTable::class); $manager = $this->container->get(TransactionManager::class); @@ -34,7 +35,7 @@ public function test_transaction_manager(): void public function test_transaction_manager_commit(): void { - $this->migrate(CreateMigrationsTable::class, CreateAuthorTable::class); + $this->migrate(CreateMigrationsTable::class, CreatePublishersTable::class, CreateAuthorTable::class); $manager = $this->container->get(TransactionManager::class); @@ -50,7 +51,7 @@ public function test_transaction_manager_commit(): void public function test_transaction_manager_commit_rollback(): void { - $this->migrate(CreateMigrationsTable::class, CreateAuthorTable::class); + $this->migrate(CreateMigrationsTable::class, CreatePublishersTable::class, CreateAuthorTable::class); $manager = $this->container->get(TransactionManager::class); diff --git a/tests/Integration/Database/QueryTest.php b/tests/Integration/Database/QueryTest.php index 55e4d83c5..6b0dd0ffa 100644 --- a/tests/Integration/Database/QueryTest.php +++ b/tests/Integration/Database/QueryTest.php @@ -7,6 +7,7 @@ use Tempest\Database\Migrations\CreateMigrationsTable; use Tempest\Database\Query; use Tests\Tempest\Fixtures\Migrations\CreateAuthorTable; +use Tests\Tempest\Fixtures\Migrations\CreatePublishersTable; use Tests\Tempest\Fixtures\Modules\Books\Models\Author; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; @@ -17,7 +18,7 @@ final class QueryTest extends FrameworkIntegrationTestCase { public function test_with_bindings(): void { - $this->migrate(CreateMigrationsTable::class, CreateAuthorTable::class); + $this->migrate(CreateMigrationsTable::class, CreatePublishersTable::class, CreateAuthorTable::class); new Author(name: 'A')->save(); new Author(name: 'B')->save(); diff --git a/tests/Integration/ORM/IsDatabaseModelTest.php b/tests/Integration/ORM/IsDatabaseModelTest.php index 49d39667d..05de1d892 100644 --- a/tests/Integration/ORM/IsDatabaseModelTest.php +++ b/tests/Integration/ORM/IsDatabaseModelTest.php @@ -140,6 +140,7 @@ public function test_all_with_relations(): void { $this->migrate( CreateMigrationsTable::class, + CreatePublishersTable::class, CreateAuthorTable::class, CreateBookTable::class, ); @@ -248,6 +249,7 @@ public function test_has_many_relations(): void { $this->migrate( CreateMigrationsTable::class, + CreatePublishersTable::class, CreateAuthorTable::class, CreateBookTable::class, ); @@ -435,6 +437,7 @@ public function test_update_or_create(): void { $this->migrate( CreateMigrationsTable::class, + CreatePublishersTable::class, CreateAuthorTable::class, CreateBookTable::class, ); diff --git a/tests/Integration/Route/RequestTest.php b/tests/Integration/Route/RequestTest.php index 3f512ee4c..aeda0a8f3 100644 --- a/tests/Integration/Route/RequestTest.php +++ b/tests/Integration/Route/RequestTest.php @@ -13,6 +13,7 @@ use Tempest\Http\Status; 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\Book; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; @@ -117,6 +118,7 @@ public function test_custom_request_test_with_validation(): void { $this->migrate( CreateMigrationsTable::class, + CreatePublishersTable::class, CreateAuthorTable::class, CreateBookTable::class, ); @@ -145,6 +147,7 @@ public function test_custom_request_test_with_nested_validation(): void { $this->migrate( CreateMigrationsTable::class, + CreatePublishersTable::class, CreateAuthorTable::class, CreateBookTable::class, ); diff --git a/tests/Integration/Route/RouterTest.php b/tests/Integration/Route/RouterTest.php index f59138a2a..89f8dcc7e 100644 --- a/tests/Integration/Route/RouterTest.php +++ b/tests/Integration/Route/RouterTest.php @@ -28,6 +28,7 @@ use Tests\Tempest\Fixtures\Controllers\UriGeneratorController; use Tests\Tempest\Fixtures\Migrations\CreateAuthorTable; use Tests\Tempest\Fixtures\Migrations\CreateBookTable; +use Tests\Tempest\Fixtures\Migrations\CreatePublishersTable; use Tests\Tempest\Fixtures\Modules\Books\Models\Author; use Tests\Tempest\Fixtures\Modules\Books\Models\Book; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; @@ -132,6 +133,7 @@ public function test_route_binding(): void { $this->migrate( CreateMigrationsTable::class, + CreatePublishersTable::class, CreateAuthorTable::class, CreateBookTable::class, );