Skip to content

Commit 72afef3

Browse files
authored
Merge pull request #2058
* test(database): add failing tests for insert with BelongsToMany, HasM… * fix(database): skip BelongsToMany, HasManyThrough, and HasOneThrough … * feat(database): auto-insert BelongsToMany pivot rows in InsertQueryBu… * fix(database): handle BelongsToMany, HasManyThrough, and HasOneThroug… * test(database): add regression tests for Select, Count, and Delete wi… * style: use named arguments in all new code in InsertQueryBuilder and … * style: use named arguments in all new test code * style: use named arguments in migration fixtures * refactor(database): simplify BelongsToMany tests to let callback inse…
1 parent 41a47bf commit 72afef3

12 files changed

Lines changed: 536 additions & 2 deletions

File tree

packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,17 @@
44

55
use Closure;
66
use Tempest\Database\BelongsTo;
7+
use Tempest\Database\BelongsToMany;
78
use Tempest\Database\Builder\ModelInspector;
89
use Tempest\Database\Database;
910
use Tempest\Database\DatabaseContext;
1011
use Tempest\Database\Exceptions\HasManyRelationCouldNotBeInsterted;
1112
use Tempest\Database\Exceptions\HasOneRelationCouldNotBeInserted;
1213
use Tempest\Database\Exceptions\ModelDidNotHavePrimaryColumn;
1314
use Tempest\Database\HasMany;
15+
use Tempest\Database\HasManyThrough;
1416
use Tempest\Database\HasOne;
17+
use Tempest\Database\HasOneThrough;
1518
use Tempest\Database\OnDatabase;
1619
use Tempest\Database\PrimaryKey;
1720
use Tempest\Database\Query;
@@ -260,6 +263,69 @@ private function addHasOneRelationCallback(string $relationName, object|iterable
260263
};
261264
}
262265

266+
private function addBelongsToManyRelationCallback(string $relationName, iterable $relations): void
267+
{
268+
$belongsToMany = $this->model->getBelongsToMany(name: $relationName);
269+
270+
if (! $belongsToMany instanceof BelongsToMany) {
271+
return;
272+
}
273+
274+
if (! $this->model->hasPrimaryKey()) {
275+
throw ModelDidNotHavePrimaryColumn::neededForRelation(model: $this->model->getName(), relationType: 'BelongsToMany');
276+
}
277+
278+
$this->after[] = function (PrimaryKey $parentId) use ($belongsToMany, $relations) {
279+
$ownerModel = inspect(model: $this->model->getName());
280+
$targetModel = inspect(model: $belongsToMany->property->getIterableType()->asClass());
281+
282+
$pivotTable = $belongsToMany->pivot ?? implode(separator: '_', array: Arr\sort(array: [$ownerModel->getTableName(), $targetModel->getTableName()]));
283+
284+
$ownerFk = $belongsToMany->ownerJoin
285+
? $this->removeTablePrefix(columnName: $belongsToMany->ownerJoin)
286+
: Intl\singularize_last_word(value: $ownerModel->getTableName()) . '_' . $ownerModel->getPrimaryKey();
287+
288+
$targetPk = $targetModel->getPrimaryKey();
289+
290+
if (! $targetPk) {
291+
throw ModelDidNotHavePrimaryColumn::neededForRelation(model: $targetModel->getName(), relationType: 'BelongsToMany');
292+
}
293+
294+
$targetFk = $belongsToMany->relatedOwnerJoin
295+
? $this->removeTablePrefix(columnName: $belongsToMany->relatedOwnerJoin)
296+
: Intl\singularize_last_word(value: $targetModel->getTableName()) . '_' . $targetPk;
297+
298+
$pivotRows = [];
299+
300+
foreach ($relations as $related) {
301+
$relatedId = match (true) {
302+
is_object($related) && isset($related->{$targetPk}) => $related->{$targetPk},
303+
is_array($related) && isset($related[$targetPk]) => $related[$targetPk],
304+
default => new InsertQueryBuilder(
305+
model: $targetModel->getName(),
306+
rows: [$related],
307+
serializerFactory: $this->serializerFactory,
308+
)->execute(),
309+
};
310+
311+
$pivotRows[] = [
312+
$ownerFk => $parentId,
313+
$targetFk => $relatedId,
314+
];
315+
}
316+
317+
if ($pivotRows === []) {
318+
return null;
319+
}
320+
321+
return new InsertQueryBuilder(
322+
model: $pivotTable,
323+
rows: $pivotRows,
324+
serializerFactory: $this->serializerFactory,
325+
);
326+
};
327+
}
328+
263329
private function handleCustomHasOneRelation(HasOne $hasOne, object|array $relation, PrimaryKey $parentId): null
264330
{
265331
$relatedModelId = new InsertQueryBuilder(
@@ -445,6 +511,22 @@ private function resolveObjectData(object $model): array
445511
continue;
446512
}
447513

514+
if ($definition->getBelongsToMany(name: $propertyName) instanceof BelongsToMany) {
515+
if (is_iterable($value)) {
516+
$this->addBelongsToManyRelationCallback(relationName: $propertyName, relations: $value);
517+
}
518+
519+
continue;
520+
}
521+
522+
if ($definition->getHasManyThrough(name: $propertyName) instanceof HasManyThrough) {
523+
continue;
524+
}
525+
526+
if ($definition->getHasOneThrough(name: $propertyName) instanceof HasOneThrough) {
527+
continue;
528+
}
529+
448530
$column = $propertyName;
449531

450532
if ($property->getType()->getName() === PrimaryKey::class && $value === null) {

packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php

Lines changed: 111 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace Tempest\Database\Builder\QueryBuilders;
44

55
use Tempest\Database\BelongsTo;
6+
use Tempest\Database\BelongsToMany;
67
use Tempest\Database\Builder\ModelInspector;
78
use Tempest\Database\Builder\WhereOperator;
89
use Tempest\Database\Database;
@@ -12,7 +13,9 @@
1213
use Tempest\Database\Exceptions\HasOneRelationCouldNotBeUpdated;
1314
use Tempest\Database\Exceptions\ModelDidNotHavePrimaryColumn;
1415
use Tempest\Database\HasMany;
16+
use Tempest\Database\HasManyThrough;
1517
use Tempest\Database\HasOne;
18+
use Tempest\Database\HasOneThrough;
1619
use Tempest\Database\OnDatabase;
1720
use Tempest\Database\PrimaryKey;
1821
use Tempest\Database\Query;
@@ -24,6 +27,7 @@
2427
use Tempest\Mapper\SerializerFactory;
2528
use Tempest\Reflection\ClassReflector;
2629
use Tempest\Reflection\PropertyReflector;
30+
use Tempest\Support\Arr;
2731
use Tempest\Support\Arr\ImmutableArray;
2832
use Tempest\Support\Conditions\HasConditions;
2933
use Tempest\Support\Str\ImmutableString;
@@ -225,7 +229,13 @@ private function resolveValuesToUpdate(): ImmutableArray
225229

226230
private function handleRelationUpdate(string $column, mixed $value): bool
227231
{
228-
return $this->handleHasManyRelation($column, $value) || $this->handleHasOneRelation($column, $value);
232+
return (
233+
$this->handleHasManyRelation(key: $column, relations: $value)
234+
|| $this->handleHasOneRelation(key: $column, relation: $value)
235+
|| $this->handleBelongsToManyRelation(key: $column, relations: $value)
236+
|| $this->handleHasManyThroughRelation(key: $column)
237+
|| $this->handleHasOneThroughRelation(key: $column)
238+
);
229239
}
230240

231241
private function resolvePropertyValue(PropertyReflector $property, string $column, mixed $value): array
@@ -323,6 +333,99 @@ private function handleHasOneRelation(string $key, mixed $relation): bool
323333
return true;
324334
}
325335

336+
private function handleBelongsToManyRelation(string $key, mixed $relations): bool
337+
{
338+
$belongsToMany = $this->model->getBelongsToMany(name: $key);
339+
340+
if (! $belongsToMany instanceof BelongsToMany) {
341+
return false;
342+
}
343+
344+
if (is_iterable($relations)) {
345+
$this->addBelongsToManyRelationCallback(relationName: $key, relations: $relations);
346+
}
347+
348+
return true;
349+
}
350+
351+
private function handleHasManyThroughRelation(string $key): bool
352+
{
353+
return $this->model->getHasManyThrough(name: $key) instanceof HasManyThrough;
354+
}
355+
356+
private function handleHasOneThroughRelation(string $key): bool
357+
{
358+
return $this->model->getHasOneThrough(name: $key) instanceof HasOneThrough;
359+
}
360+
361+
private function addBelongsToManyRelationCallback(string $relationName, iterable $relations): void
362+
{
363+
$belongsToMany = $this->model->getBelongsToMany(name: $relationName);
364+
365+
if (! $belongsToMany instanceof BelongsToMany) {
366+
return;
367+
}
368+
369+
$this->ensureModelHasPrimaryKey(model: $this->model, relationType: 'BelongsToMany');
370+
371+
$this->after[] = function (PrimaryKey $parentId) use ($belongsToMany, $relations) {
372+
$ownerModel = inspect(model: $this->model->getName());
373+
$targetModel = inspect(model: $belongsToMany->property->getIterableType()->asClass());
374+
375+
$pivotTable = $belongsToMany->pivot ?? implode(separator: '_', array: Arr\sort(array: [$ownerModel->getTableName(), $targetModel->getTableName()]));
376+
377+
$ownerFk = $belongsToMany->ownerJoin
378+
? $this->removeTablePrefix(column: $belongsToMany->ownerJoin)
379+
: Intl\singularize_last_word(value: $ownerModel->getTableName()) . '_' . $ownerModel->getPrimaryKey();
380+
381+
$targetPk = $targetModel->getPrimaryKey();
382+
383+
if (! $targetPk) {
384+
throw ModelDidNotHavePrimaryColumn::neededForRelation(model: $targetModel->getName(), relationType: 'BelongsToMany');
385+
}
386+
387+
$targetFk = $belongsToMany->relatedOwnerJoin
388+
? $this->removeTablePrefix(column: $belongsToMany->relatedOwnerJoin)
389+
: Intl\singularize_last_word(value: $targetModel->getTableName()) . '_' . $targetPk;
390+
391+
// Delete existing pivot rows
392+
new DeleteQueryBuilder(model: $pivotTable)
393+
->whereField(field: $ownerFk, value: $parentId->value)
394+
->build()
395+
->onDatabase($this->onDatabase)
396+
->execute();
397+
398+
$pivotRows = [];
399+
400+
foreach ($relations as $related) {
401+
$relatedId = match (true) {
402+
is_object($related) && isset($related->{$targetPk}) => $related->{$targetPk},
403+
is_array($related) && isset($related[$targetPk]) => $related[$targetPk],
404+
default => new InsertQueryBuilder(
405+
model: $targetModel->getName(),
406+
rows: [$related],
407+
serializerFactory: $this->serializerFactory,
408+
)->execute(),
409+
};
410+
411+
$pivotRows[] = [
412+
$ownerFk => $parentId,
413+
$targetFk => $relatedId,
414+
];
415+
}
416+
417+
if ($pivotRows === []) {
418+
return null;
419+
}
420+
421+
return new InsertQueryBuilder(
422+
model: $pivotTable,
423+
rows: $pivotRows,
424+
serializerFactory: $this->serializerFactory,
425+
);
426+
};
427+
}
428+
326429
private function addHasManyRelationCallback(string $relationName, iterable $relations): void
327430
{
328431
$hasMany = $this->model->getHasMany($relationName);
@@ -600,7 +703,13 @@ private function hasRelationUpdates(): bool
600703

601704
private function isRelationField(string $field): bool
602705
{
603-
return $this->model->getHasMany($field) || $this->model->getHasOne($field);
706+
return (
707+
$this->model->getHasMany(name: $field)
708+
|| $this->model->getHasOne(name: $field)
709+
|| $this->model->getBelongsToMany(name: $field) instanceof BelongsToMany
710+
|| $this->model->getHasManyThrough(name: $field) instanceof HasManyThrough
711+
|| $this->model->getHasOneThrough(name: $field) instanceof HasOneThrough
712+
);
604713
}
605714

606715
private function validateRelationUpdateConstraints(): void
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Tempest\Fixtures\Migrations;
6+
7+
use Tempest\Database\MigratesDown;
8+
use Tempest\Database\MigratesUp;
9+
use Tempest\Database\QueryStatement;
10+
use Tempest\Database\QueryStatements\CreateTableStatement;
11+
use Tempest\Database\QueryStatements\DropTableStatement;
12+
13+
final class CreateBookTagTable implements MigratesUp, MigratesDown
14+
{
15+
private(set) string $name = '0000-00-11_create_books_tags_table';
16+
17+
public function up(): QueryStatement
18+
{
19+
return new CreateTableStatement(tableName: 'books_tags')
20+
->primary()
21+
->belongsTo(local: 'books_tags.book_id', foreign: 'books.id')
22+
->belongsTo(local: 'books_tags.tag_id', foreign: 'tags.id');
23+
}
24+
25+
public function down(): QueryStatement
26+
{
27+
return new DropTableStatement(tableName: 'books_tags');
28+
}
29+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Tempest\Fixtures\Migrations;
6+
7+
use Tempest\Database\MigratesDown;
8+
use Tempest\Database\MigratesUp;
9+
use Tempest\Database\QueryStatement;
10+
use Tempest\Database\QueryStatements\CreateTableStatement;
11+
use Tempest\Database\QueryStatements\DropTableStatement;
12+
use Tests\Tempest\Fixtures\Modules\Books\Models\Tag;
13+
14+
final class CreateTagTable implements MigratesUp, MigratesDown
15+
{
16+
private(set) string $name = '0000-00-10_create_tags_table';
17+
18+
public function up(): QueryStatement
19+
{
20+
return CreateTableStatement::forModel(modelClass: Tag::class)
21+
->primary()
22+
->text(name: 'label');
23+
}
24+
25+
public function down(): QueryStatement
26+
{
27+
return DropTableStatement::forModel(modelClass: Tag::class);
28+
}
29+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Tempest\Fixtures\Modules\Books\Models;
6+
7+
use Tempest\Database\IsDatabaseModel;
8+
9+
final class BookReview
10+
{
11+
use IsDatabaseModel;
12+
13+
public string $content;
14+
15+
public ?Tag $tag = null;
16+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Tempest\Fixtures\Modules\Books\Models;
6+
7+
use Tempest\Database\IsDatabaseModel;
8+
9+
final class Reviewer
10+
{
11+
use IsDatabaseModel;
12+
13+
public string $name;
14+
15+
public ?BookReview $bookReview = null;
16+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Tempest\Fixtures\Modules\Books\Models;
6+
7+
use Tempest\Database\BelongsToMany;
8+
use Tempest\Database\HasManyThrough;
9+
use Tempest\Database\HasOneThrough;
10+
use Tempest\Database\IsDatabaseModel;
11+
12+
final class Tag
13+
{
14+
use IsDatabaseModel;
15+
16+
public string $label;
17+
18+
/** @var \Tests\Tempest\Fixtures\Modules\Books\Models\Book[] */
19+
#[BelongsToMany]
20+
public array $books = [];
21+
22+
/** @var \Tests\Tempest\Fixtures\Modules\Books\Models\Reviewer[] */
23+
#[HasManyThrough(through: BookReview::class)]
24+
public array $reviewers = [];
25+
26+
#[HasOneThrough(through: BookReview::class)]
27+
public ?Reviewer $topReviewer = null;
28+
}

0 commit comments

Comments
 (0)