diff --git a/src/Phaseolies/Database/Entity/Builder.php b/src/Phaseolies/Database/Entity/Builder.php index 5c8eed29..d03b891d 100644 --- a/src/Phaseolies/Database/Entity/Builder.php +++ b/src/Phaseolies/Database/Entity/Builder.php @@ -37,6 +37,7 @@ class Builder use InteractsWithNestedRelations; use InteractsWithConditionBinding, InteractsWithSoftDeleteScope { InteractsWithSoftDeleteScope::buildWhereClause insteadof InteractsWithConditionBinding; + InteractsWithSoftDeleteScope::buildWhereClause as protected buildSoftDeleteWhereClause; InteractsWithConditionBinding::buildWhereClause as protected buildConditionWhereClause; } use InteractsWithCursorPagination; @@ -145,6 +146,13 @@ class Builder */ protected bool $withHooks = false; + /** + * The condition that ties this query to the model it was reached from + * + * @var array{0: string, 1: array}|null + */ + protected ?array $relationScope = null; + /** * @var bool */ @@ -173,6 +181,94 @@ public function __construct(PDO $pdo, string $table, string $modelClass, int $ro $this->connectionName = $connectionName; } + /** + * Combine the query's conditions with the relation scope, if there is one + * + * @return array [sql, bindings] + */ + protected function buildWhereClause(): array + { + [$sql, $bindings] = $this->buildSoftDeleteWhereClause(); + + if ($this->relationScope === null) { + return [$sql, $bindings]; + } + + [$scopeSql, $scopeBindings] = $this->relationScope; + + return [ + $sql === '' ? $scopeSql : "({$sql}) AND {$scopeSql}", + array_merge($bindings, $scopeBindings), + ]; + } + + /** + * Limit this query to rows whose column equals the value. + * + * @param string $column + * @param mixed $value + * @return static + */ + public function constrainToColumn(string $column, mixed $value): static + { + if ($value === null) { + return $this->constrainToNothing(); + } + + $this->relationScope = [ + $this->quoteIdentifier($this->tableReference() . '.' . $column) . ' = ?', + [$value], + ]; + + return $this; + } + + /** + * Limit this query to rows that the pivot table links to the parent + * + * @param string $pivotTable + * @param string $foreignKey + * @param string $relatedKey + * @param string $ownerKey + * @param mixed $parentKey + * @return static + */ + public function constrainToPivot( + string $pivotTable, + string $foreignKey, + string $relatedKey, + string $ownerKey, + mixed $parentKey + ): static { + if ($parentKey === null) { + return $this->constrainToNothing(); + } + + $quote = fn (string $identifier) => $this->quoteIdentifier($identifier); + + $this->relationScope = [ + $quote($this->tableReference() . '.' . $ownerKey) + . ' IN (SELECT ' . $quote($pivotTable . '.' . $relatedKey) + . ' FROM ' . $quote($pivotTable) + . ' WHERE ' . $quote($pivotTable . '.' . $foreignKey) . ' = ?)', + [$parentKey], + ]; + + return $this; + } + + /** + * Limit this query to rows that will never match + * + * @return static + */ + private function constrainToNothing(): static + { + $this->relationScope = ['1 = 0', []]; + + return $this; + } + /** * Skip model hooks for the next write on every model this builder * creates or loads. diff --git a/src/Phaseolies/Database/Entity/Model.php b/src/Phaseolies/Database/Entity/Model.php index fa0dd786..f8eef04b 100644 --- a/src/Phaseolies/Database/Entity/Model.php +++ b/src/Phaseolies/Database/Entity/Model.php @@ -845,7 +845,8 @@ public function linkOne(string $related, ?string $foreignKey = null, ?string $lo $this->rememberRelation('linkOne', $related, $foreignKey, $localKey); $query = $this->newRelationBuilder(LinkOne::class, $related); - $query->where($foreignKey, '=', $this->$localKey); + $this->constrainRelation($query, $foreignKey, $this->$localKey); + $query->setRelationParent($this, $foreignKey, $localKey); return $query; } @@ -868,7 +869,8 @@ public function bindTo(string $related, ?string $foreignKey = null, ?string $loc $this->rememberRelation('bindTo', $related, $foreignKey, $localKey); $query = $this->newRelationBuilder(BindTo::class, $related); - $query->where($foreignKey, '=', $this->$localKey); + $this->constrainRelation($query, $foreignKey, $this->$localKey); + $query->setRelationChild($this, $localKey, $foreignKey); return $query; } @@ -889,7 +891,8 @@ public function linkMany(string $related, ?string $foreignKey = null, ?string $l $this->rememberRelation('linkMany', $related, $foreignKey, $localKey); $query = $this->newRelationBuilder(LinkMany::class, $related); - $query->where($foreignKey, '=', $this->$localKey); + $this->constrainRelation($query, $foreignKey, $this->$localKey); + $query->setRelationParent($this, $foreignKey, $localKey); return $query; } @@ -938,9 +941,24 @@ public function bindToMany( 'parentKey' => $this->getKey() ]); + $query->constrainToPivot($pivotTable, $foreignKey, $relatedKey, $relatedModel->getKeyName(), $this->getKey()); + return $query; } + /** + * Limit a relation query to the rows that point at this model + * + * @param Builder $query + * @param string $foreignKey + * @param mixed $value + * @return void + */ + private function constrainRelation(Builder $query, string $foreignKey, mixed $value): void + { + $query->constrainToColumn($foreignKey, $value); + } + /** * Remember the relationship that was just defined, for eager loading * @@ -981,13 +999,38 @@ private function newRelationBuilder(string $builder, string $related): Builder $relatedInstance = app($related); $connection = $relatedInstance->connection; - return new $builder( + $relation = new $builder( Database::getPdoInstance($connection), $relatedInstance->getTable(), $related, $relatedInstance->pageSize, $connection ); + + $source = $related::query(); + + foreach (self::builderProperties() as $property) { + if ($property->isInitialized($source)) { + $property->setValue($relation, $property->getValue($source)); + } + } + + return $relation; + } + + /** + * The instance properties of the query builder, read once + * + * @return array + */ + private static function builderProperties(): array + { + static $properties = null; + + return $properties ??= array_values(array_filter( + (new \ReflectionClass(Builder::class))->getProperties(), + fn (\ReflectionProperty $property) => !$property->isStatic() + )); } /** @@ -1001,6 +1044,17 @@ private static function snakeCase(string $value): string return strtolower((string) preg_replace('/(?creatable, true); + } + /** * Get the last relation type * diff --git a/src/Phaseolies/Database/Entity/Relations/BindTo.php b/src/Phaseolies/Database/Entity/Relations/BindTo.php index 5606f469..426e166a 100644 --- a/src/Phaseolies/Database/Entity/Relations/BindTo.php +++ b/src/Phaseolies/Database/Entity/Relations/BindTo.php @@ -3,8 +3,104 @@ namespace Phaseolies\Database\Entity\Relations; use Phaseolies\Database\Entity\Builder; +use Phaseolies\Database\Entity\Model; class BindTo extends Builder { - // + /** + * @var Model|null + */ + protected ?Model $child = null; + + /** + * @var string + */ + protected string $childKey = ''; + + /** + * @var string + */ + protected string $ownerKey = ''; + + /** + * Remember the model that owns the foreign key, and the columns that tie it to its parent + * + * @param Model $child + * @param string $childKey + * @param string $ownerKey + * @return static + */ + public function setRelationChild(Model $child, string $childKey, string $ownerKey): static + { + $this->child = $child; + $this->childKey = $childKey; + $this->ownerKey = $ownerKey; + + return $this; + } + + /** + * Point the child at a parent. The child is not saved: call save() on it. + * + * @param Model|int|string $parent + * @return Model The child + * @throws \InvalidArgumentException + * @throws \LogicException + */ + public function associate(Model|int|string $parent): Model + { + $expected = $this->modelClass; + + if ($parent instanceof Model && !($parent instanceof $expected)) { + throw new \InvalidArgumentException( + 'Expected an instance of ' . $expected . ', got ' . $parent::class . '.' + ); + } + + $child = $this->childModel(); + + $value = $parent instanceof Model ? $parent->{$this->ownerKey} : $parent; + + if ($value === null) { + throw new \LogicException('Cannot associate an unsaved ' . get_debug_type($parent) . ': its [' . $this->ownerKey . '] is empty.'); + } + + $child->setAttribute($this->childKey, $value); + + return $child; + } + + /** + * Remove the link to the parent. The child is not saved: call save() on it. + * + * @return Model The child + * @throws \LogicException + */ + public function dissociate(): Model + { + $child = $this->childModel(); + + $child->setAttribute($this->childKey, null); + + return $child; + } + + /** + * @return Model + * @throws \LogicException + */ + private function childModel(): Model + { + if ($this->child === null) { + throw new \LogicException('This relation was not created from a model.'); + } + + if (!$this->child->isCreatable($this->childKey)) { + throw new \LogicException( + 'Add [' . $this->childKey . '] to ' . $this->child::class . '::$creatable so the change can be saved.' + ); + } + + return $this->child; + } } diff --git a/src/Phaseolies/Database/Entity/Relations/BindToMany.php b/src/Phaseolies/Database/Entity/Relations/BindToMany.php index 73b9fab1..930004cd 100644 --- a/src/Phaseolies/Database/Entity/Relations/BindToMany.php +++ b/src/Phaseolies/Database/Entity/Relations/BindToMany.php @@ -2,9 +2,174 @@ namespace Phaseolies\Database\Entity\Relations; +use Phaseolies\Database\Database; use Phaseolies\Database\Entity\Builder; +use Phaseolies\Database\Entity\Model; +use Phaseolies\Support\Collection; class BindToMany extends Builder { - // + /** + * Create a related record and link it to the parent + * + * @param array $attributes + * @param array $pivotData + * @return Model + * @throws \LogicException + */ + public function create(array $attributes, array $pivotData = []): Model + { + $this->assertParentIsSaved(); + + return $this->inTransaction(function () use ($attributes, $pivotData) { + $model = parent::create($attributes); + + $this->link([$model->getKey()], $pivotData); + + return $model; + }); + } + + /** + * Create several related records and link them to the parent. + * + * @param array $records + * @param array $pivotData + * @return Collection + * @throws \LogicException + */ + public function createMany(array $records, array $pivotData = []): Collection + { + $this->assertParentIsSaved(); + + return $this->inTransaction(function () use ($records, $pivotData) { + $models = []; + + foreach ($records as $attributes) { + $models[] = $this->create($attributes, $pivotData); + } + + return new Collection($this->modelClass, $models); + }); + } + + /** + * Save a model and link it to the parent + * + * @param Model $related + * @param array $pivotData + * @return Model|false + * @throws \InvalidArgumentException + * @throws \LogicException + */ + public function store(Model $related, array $pivotData = []): Model|false + { + $this->assertParentIsSaved(); + + $expected = $this->modelClass; + + if (!($related instanceof $expected)) { + throw new \InvalidArgumentException( + 'Expected an instance of ' . $expected . ', got ' . $related::class . '.' + ); + } + + return $this->inTransaction(function () use ($related, $pivotData) { + if ($this->withoutHooks) { + $related->withoutHook(); + } + + if (!$related->save()) { + return false; + } + + $this->linkOnce($related->getKey(), $pivotData); + + return $related; + }); + } + + /** + * Save several models and link them to the parent, in one transaction + * + * @param iterable $models + * @param array $pivotData + * @return array + * @throws \InvalidArgumentException + * @throws \LogicException + */ + public function storeMany(iterable $models, array $pivotData = []): array + { + $this->assertParentIsSaved(); + + return $this->inTransaction(function () use ($models, $pivotData) { + $stored = []; + + foreach ($models as $model) { + $stored[] = $this->store($model, $pivotData); + } + + return $stored; + }); + } + + /** + * Link a related model to the parent, if not already linked. If already linked, update the pivot data. + * + * @param mixed $id + * @param array $pivotData + * @return void + */ + private function linkOnce(mixed $id, array $pivotData): void + { + $info = $this->relationInfo; + + $linked = $this->getCurrentRelatedIds( + $info['pivotTable'], + $info['foreignKey'], + $info['relatedKey'], + $info['parentKey'] + ); + + if (!in_array($id, $linked)) { + $this->link([$id], $pivotData); + + return; + } + + if ($pivotData !== []) { + $this->updatePivot( + $id, + $pivotData, + $info['pivotTable'], + $info['foreignKey'], + $info['relatedKey'], + $info['parentKey'] + ); + } + } + + /** + * Run a callback in a database transaction + * + * @param \Closure $callback + * @return mixed + */ + private function inTransaction(\Closure $callback): mixed + { + return (new Database($this->connectionName))->transaction($callback); + } + + /** + * Assert that the parent model is saved (has a primary key) before performing operations that require it. + * + * @return void + * @throws \LogicException + */ + private function assertParentIsSaved(): void + { + if (empty($this->relationInfo['parentKey'] ?? null)) { + throw new \LogicException('Cannot write through a relation of an unsaved model: its key is empty.'); + } + } } diff --git a/src/Phaseolies/Database/Entity/Relations/InteractsWithRelationWrites.php b/src/Phaseolies/Database/Entity/Relations/InteractsWithRelationWrites.php new file mode 100644 index 00000000..340bf292 --- /dev/null +++ b/src/Phaseolies/Database/Entity/Relations/InteractsWithRelationWrites.php @@ -0,0 +1,161 @@ +parent = $parent; + $this->parentForeignKey = $foreignKey; + $this->parentLocalKey = $localKey; + + return $this; + } + + /** + * Create a related record with the foreign key already set + * + * @param array $attributes + * @return Model + * @throws \LogicException + */ + public function create(array $attributes): Model + { + $this->assertCanWrite(); + + return parent::create(array_merge($attributes, [ + $this->parentForeignKey => $this->parentKeyValue(), + ])); + } + + /** + * Create several related records. Each one is a separate insert + * + * @param array $records + * @return Collection + * @throws \LogicException + */ + public function createMany(array $records): Collection + { + $this->assertCanWrite(); + + $models = []; + + foreach ($records as $attributes) { + $models[] = $this->create($attributes); + } + + return new Collection($this->modelClass, $models); + } + + /** + * Save an existing or new model as a related record + * + * @param Model $related + * @return Model|false + * @throws \LogicException + */ + public function store(Model $related): Model|false + { + $this->assertCanWrite(); + + $expected = $this->modelClass; + + if (!($related instanceof $expected)) { + throw new \InvalidArgumentException( + 'Expected an instance of ' . $expected . ', got ' . $related::class . '.' + ); + } + + $related->setAttribute($this->parentForeignKey, $this->parentKeyValue()); + + if ($this->withoutHooks) { + $related->withoutHook(); + } + + return $related->save() ? $related : false; + } + + /** + * Save several models as related records + * + * @param iterable $models + * @return array + * @throws \LogicException + */ + public function storeMany(iterable $models): array + { + $saved = []; + + foreach ($models as $model) { + $saved[] = $this->store($model); + } + + return $saved; + } + + /** + * The parent's value of the local key + * + * @return mixed + * @throws \LogicException + */ + protected function parentKeyValue(): mixed + { + $value = $this->parent?->{$this->parentLocalKey}; + + if ($value === null) { + throw new \LogicException( + 'Cannot write through a relation of an unsaved ' . ($this->parent ? $this->parent::class : 'model') + . ': its [' . $this->parentLocalKey . '] is empty.' + ); + } + + return $value; + } + + /** + * @return void + * @throws \LogicException + */ + protected function assertCanWrite(): void + { + if ($this->parent === null) { + throw new \LogicException('This relation was not created from a model, so it cannot write.'); + } + + if (!app($this->modelClass)->isCreatable($this->parentForeignKey)) { + throw new \LogicException( + 'Add [' . $this->parentForeignKey . '] to ' . $this->modelClass + . '::$creatable to create records through this relation.' + ); + } + } +} diff --git a/src/Phaseolies/Database/Entity/Relations/LinkMany.php b/src/Phaseolies/Database/Entity/Relations/LinkMany.php index dc107a45..25b56a83 100644 --- a/src/Phaseolies/Database/Entity/Relations/LinkMany.php +++ b/src/Phaseolies/Database/Entity/Relations/LinkMany.php @@ -6,5 +6,5 @@ class LinkMany extends Builder { - // + use InteractsWithRelationWrites; } diff --git a/src/Phaseolies/Database/Entity/Relations/LinkOne.php b/src/Phaseolies/Database/Entity/Relations/LinkOne.php index f9d493bb..09637e80 100644 --- a/src/Phaseolies/Database/Entity/Relations/LinkOne.php +++ b/src/Phaseolies/Database/Entity/Relations/LinkOne.php @@ -6,5 +6,5 @@ class LinkOne extends Builder { - // + use InteractsWithRelationWrites; } diff --git a/tests/Model/Relations/RelationDefaultsBehavior.php b/tests/Model/Relations/RelationDefaultsBehavior.php index 038d69d6..2a57a52f 100644 --- a/tests/Model/Relations/RelationDefaultsBehavior.php +++ b/tests/Model/Relations/RelationDefaultsBehavior.php @@ -136,6 +136,36 @@ public function testEagerLoadingWorksWithDefaultedRelations(): void $this->assertSame(['php', 'sql'], $articles[0]->topics->pluck('name')->all()); } + public function testRelationsOfAnUnsavedModelMatchNothing(): void + { + // A row whose foreign key is NULL must not be picked up by a model without a key + $this->pdo->exec("INSERT INTO articles (author_id, title) VALUES (NULL, 'Orphan')"); + $this->pdo->exec("INSERT INTO bios (author_id, line) VALUES (NULL, 'Orphan bio')"); + + $author = new Author(); + + $this->assertCount(0, $author->articles); + $this->assertNull($author->bio); + $this->assertSame(0, $author->articles()->count()); + $this->assertNull((new Article())->author); + } + + public function testRelationsApplyAConditionAddedByTheRelatedModelsQueryMethod(): void + { + $this->pdo->exec("INSERT INTO articles (author_id, title) VALUES (1, 'Hidden')"); + + $author = Author::find(1); + + // The plain relation sees all three articles of author 1 + $this->assertSame(['Alpha', 'Beta', 'Hidden'], $author->articles->pluck('title')->all()); + + // VisibleArticle::query() adds title != 'Hidden', and the relation keeps it + $this->assertSame(['Alpha', 'Beta'], $author->visibleArticles->pluck('title')->all()); + $this->assertSame(2, $author->visibleArticles()->count()); + $this->assertInstanceOf(LinkMany::class, $author->visibleArticles()); + $this->assertSame(['Alpha'], $author->visibleArticles()->where('title', 'Alpha')->get()->pluck('title')->all()); + } + public function testRelationQueriesCanStillBeConstrained(): void { $this->assertSame(['Beta'], Author::find(1)->articles()->where('title', 'Beta')->get()->pluck('title')->all()); @@ -160,6 +190,11 @@ public function articles(): LinkMany return $this->linkMany(Article::class); } + public function visibleArticles(): LinkMany + { + return $this->linkMany(VisibleArticle::class, 'author_id'); + } + public function explicitBio(): LinkOne { return $this->linkOne(Bio::class, 'author_id', 'id'); @@ -229,3 +264,19 @@ public function articles(): BindToMany return $this->bindToMany(Article::class); } } + +/** + * Overrides query() to hide some rows from every query on the model. + */ +class VisibleArticle extends Model +{ + protected $table = 'articles'; + protected $connection = 'default'; + protected $timeStamps = false; + protected $creatable = ['author_id', 'title']; + + public static function query(?string $connection = null): Builder + { + return parent::query($connection)->where('title', '!=', 'Hidden'); + } +} diff --git a/tests/Model/Relations/RelationManyToManyWritesBehavior.php b/tests/Model/Relations/RelationManyToManyWritesBehavior.php new file mode 100644 index 00000000..87a3151d --- /dev/null +++ b/tests/Model/Relations/RelationManyToManyWritesBehavior.php @@ -0,0 +1,458 @@ + [ + ['name' => 'id', 'type' => 'id'], + ['name' => 'title', 'type' => 'string'], + ], + 'topics' => [ + ['name' => 'id', 'type' => 'id'], + ['name' => 'name', 'type' => 'string'], + ], + 'article_topic' => [ + ['name' => 'article_id', 'type' => 'integer', 'nullable' => true], + ['name' => 'topic_id', 'type' => 'integer', 'nullable' => true], + ['name' => 'note', 'type' => 'string', 'nullable' => true], + ], + ]; + } + + protected function seedData(): array + { + return [ + 'articles' => [['title' => 'Alpha'], ['title' => 'Beta']], + 'topics' => [['name' => 'php'], ['name' => 'sql']], + // Alpha: php | Beta: sql + 'article_topic' => [ + ['article_id' => 1, 'topic_id' => 1, 'note' => null], + ['article_id' => 2, 'topic_id' => 2, 'note' => null], + ], + ]; + } + + private function scalar(string $sql) + { + return $this->pdo->query($sql)->fetchColumn(); + } + + private function names(iterable $models): array + { + $names = []; + foreach ($models as $model) { + $names[] = $model->name; + } + sort($names); + + return $names; + } + + private function assertNoTransactionLeft(): void + { + $this->assertSame(0, Database::transactionLevel('default')); + $this->assertFalse($this->pdo->inTransaction()); + } + + // ------------------------------------------------------------ create + + public function testCreateAddsTheRecordAndLinksItToTheParent(): void + { + $article = Article::find(1); + + $topic = $article->topics()->create(['name' => 'new']); + + $this->assertInstanceOf(Topic::class, $topic); + $this->assertNotNull($topic->id); + $this->assertSame(1, (int) $this->scalar("SELECT COUNT(*) FROM article_topic WHERE article_id = 1 AND topic_id = {$topic->id}")); + $this->assertSame(1, (int) $this->scalar("SELECT COUNT(*) FROM article_topic WHERE topic_id = {$topic->id}"), 'linked to this article only'); + $this->assertSame(['new', 'php'], $this->names($article->topics()->get())); + $this->assertSame(['new', 'php'], $this->names(Article::find(1)->topics), 'the property sees it too'); + $this->assertSame(['sql'], $this->names(Article::find(2)->topics()->get()), 'other articles are untouched'); + $this->assertSame(3, (int) $this->scalar('SELECT COUNT(*) FROM topics')); + $this->assertNoTransactionLeft(); + } + + public function testCreateCanSetPivotColumns(): void + { + $topic = Article::find(1)->topics()->create(['name' => 'new'], ['note' => 'added by test']); + + $this->assertSame('added by test', $this->scalar("SELECT note FROM article_topic WHERE topic_id = {$topic->id}")); + } + + public function testCreateWorksFromTheInverseSide(): void + { + $article = Topic::find(2)->articles()->create(['title' => 'Gamma']); + + $this->assertSame(1, (int) $this->scalar("SELECT COUNT(*) FROM article_topic WHERE topic_id = 2 AND article_id = {$article->id}")); + $this->assertSame(['Beta', 'Gamma'], $this->titles(Topic::find(2)->articles()->get())); + } + + public function testCreateWorksWithExplicitKeys(): void + { + $topic = Article::find(1)->explicitTopics()->create(['name' => 'explicit']); + + $this->assertSame(1, (int) $this->scalar("SELECT COUNT(*) FROM article_topic WHERE article_id = 1 AND topic_id = {$topic->id}")); + } + + private function titles(iterable $models): array + { + $titles = []; + foreach ($models as $model) { + $titles[] = $model->title; + } + sort($titles); + + return $titles; + } + + public function testCreateKeepsNothingWhenTheRecordCannotBeSaved(): void + { + try { + Article::find(1)->lockedTopics()->create(['name' => 'nope']); // LockedTopic has no $creatable + $this->fail('Expected an exception'); + } catch (\RuntimeException $e) { + $this->assertStringContainsString('creatable', $e->getMessage()); + } + + $this->assertSame(2, (int) $this->scalar('SELECT COUNT(*) FROM topics')); + $this->assertSame(2, (int) $this->scalar('SELECT COUNT(*) FROM article_topic')); + $this->assertNoTransactionLeft(); + } + + public function testCreateRollsBackTheRecordWhenTheLinkFails(): void + { + try { + // the pivot table has no such column, so the link cannot be written + Article::find(1)->topics()->create(['name' => 'orphan'], ['no_such_column' => 'x']); + $this->fail('Expected an exception'); + } catch (\RuntimeException $e) { + $this->assertStringContainsString('Failed to create relationship', $e->getMessage()); + } + + $this->assertSame(2, (int) $this->scalar('SELECT COUNT(*) FROM topics'), 'the topic was rolled back'); + $this->assertSame(2, (int) $this->scalar('SELECT COUNT(*) FROM article_topic')); + $this->assertNoTransactionLeft(); + } + + // ------------------------------------------------------------ createMany + + public function testCreateManyReturnsCollectionAndLinksEveryRecord(): void + { + $topics = Article::find(2)->topics()->createMany([['name' => 'one'], ['name' => 'two']]); + + $this->assertInstanceOf(Collection::class, $topics); + $this->assertCount(2, $topics); + $this->assertSame(['one', 'sql', 'two'], $this->names(Article::find(2)->topics()->get())); + $this->assertSame(3, (int) $this->scalar('SELECT COUNT(*) FROM article_topic WHERE article_id = 2')); + $this->assertNoTransactionLeft(); + } + + public function testCreateManyIsAllOrNothing(): void + { + try { + Article::find(1)->topics()->createMany([['name' => 'fine'], ['name' => 'bad'], ['name' => 'never']]); + $this->fail('Expected the hook exception'); + } catch (\RuntimeException $e) { + $this->assertSame('bad topic', $e->getMessage()); + } + + $this->assertSame(2, (int) $this->scalar('SELECT COUNT(*) FROM topics'), 'no topic was kept'); + $this->assertSame(2, (int) $this->scalar('SELECT COUNT(*) FROM article_topic'), 'no link was kept'); + $this->assertNoTransactionLeft(); + } + + public function testCreateManyWithPivotDataAndNoRecords(): void + { + Article::find(1)->topics()->createMany([['name' => 'a'], ['name' => 'b']], ['note' => 'bulk']); + + $this->assertSame(2, (int) $this->scalar("SELECT COUNT(*) FROM article_topic WHERE note = 'bulk'")); + $this->assertCount(0, Article::find(1)->topics()->createMany([])); + } + + // ------------------------------------------------------------ store + + public function testStoreSavesANewModelAndLinksIt(): void + { + $topic = new Topic(); + $topic->name = 'stored'; + + $this->assertSame($topic, Article::find(1)->topics()->store($topic)); + $this->assertNotNull($topic->id); + $this->assertSame(1, (int) $this->scalar("SELECT COUNT(*) FROM article_topic WHERE article_id = 1 AND topic_id = {$topic->id}")); + } + + public function testStoreLinksAnExistingModelOnce(): void + { + $article = Article::find(1); + $sql = Topic::find(2); // linked to Beta, not to Alpha + + $article->topics()->store($sql); + $article->topics()->store($sql); + $article->topics()->store($sql); + + $this->assertSame(1, (int) $this->scalar('SELECT COUNT(*) FROM article_topic WHERE article_id = 1 AND topic_id = 2')); + $this->assertSame(1, (int) $this->scalar('SELECT COUNT(*) FROM article_topic WHERE article_id = 2 AND topic_id = 2'), 'the other link stays'); + $this->assertSame(['php', 'sql'], $this->names($article->topics()->get())); + } + + public function testStoreDoesNotDuplicateAnAlreadyLinkedModel(): void + { + $php = Topic::find(1); + $php->name = 'php 8'; + + Article::find(1)->topics()->store($php); + + $this->assertSame(1, (int) $this->scalar('SELECT COUNT(*) FROM article_topic WHERE article_id = 1 AND topic_id = 1')); + $this->assertSame('php 8', $this->scalar('SELECT name FROM topics WHERE id = 1'), 'the model itself is saved'); + } + + public function testStoreWithPivotDataUpdatesTheStoredValuesOfAnExistingLink(): void + { + $article = Article::find(1); + + $article->topics()->store(Topic::find(1), ['note' => 'first']); + $this->assertSame('first', $this->scalar('SELECT note FROM article_topic WHERE article_id = 1 AND topic_id = 1')); + $this->assertSame(1, (int) $this->scalar('SELECT COUNT(*) FROM article_topic WHERE article_id = 1 AND topic_id = 1')); + + $article->topics()->store(Topic::find(1), ['note' => 'second']); + $this->assertSame('second', $this->scalar('SELECT note FROM article_topic WHERE article_id = 1 AND topic_id = 1')); + + $article->topics()->store(Topic::find(1)); // no pivot data: left as it is + $this->assertSame('second', $this->scalar('SELECT note FROM article_topic WHERE article_id = 1 AND topic_id = 1')); + } + + public function testStoreManyLinksEachModelOnce(): void + { + $new = new Topic(); + $new->name = 'fresh'; + + $stored = Article::find(1)->topics()->storeMany([$new, Topic::find(2), Topic::find(1)]); + + $this->assertCount(3, $stored); + $this->assertSame($new, $stored[0]); + $this->assertSame(['fresh', 'php', 'sql'], $this->names(Article::find(1)->topics()->get())); + $this->assertSame(3, (int) $this->scalar('SELECT COUNT(*) FROM article_topic WHERE article_id = 1')); + $this->assertNoTransactionLeft(); + } + + public function testStoreManyIsAllOrNothing(): void + { + $good = new Topic(); + $good->name = 'good'; + $bad = new Topic(); + $bad->name = 'bad'; + + try { + Article::find(1)->topics()->storeMany([$good, $bad]); + $this->fail('Expected the hook exception'); + } catch (\RuntimeException) { + } + + $this->assertSame(2, (int) $this->scalar('SELECT COUNT(*) FROM topics')); + $this->assertSame(2, (int) $this->scalar('SELECT COUNT(*) FROM article_topic')); + $this->assertNoTransactionLeft(); + } + + public function testStoreRejectsAModelOfTheWrongClassAndChangesNothing(): void + { + $this->expectException(\InvalidArgumentException::class); + + try { + Article::find(1)->topics()->store(Article::find(2)); + } finally { + $this->assertSame(2, (int) $this->scalar('SELECT COUNT(*) FROM article_topic')); + } + } + + // ------------------------------------------------------------ guards + + public function testWritingThroughARelationOfAnUnsavedModelThrowsAndChangesNothing(): void + { + foreach ([ + fn () => (new Article())->topics()->create(['name' => 'x']), + fn () => (new Article())->topics()->createMany([['name' => 'x']]), + fn () => (new Article())->topics()->store(new Topic()), + fn () => (new Article())->topics()->storeMany([new Topic()]), + ] as $write) { + try { + $write(); + $this->fail('Expected a LogicException'); + } catch (\LogicException $e) { + $this->assertStringContainsString('unsaved', $e->getMessage()); + } + } + + $this->assertSame(2, (int) $this->scalar('SELECT COUNT(*) FROM topics')); + $this->assertSame(2, (int) $this->scalar('SELECT COUNT(*) FROM article_topic')); + } + + // ------------------------------------------------------------ firstOrCreate / updateOrCreate + + public function testFirstOrCreateStaysInsideTheParentsRecords(): void + { + $article = Article::find(1); + + $found = $article->topics()->firstOrCreate(['name' => 'php']); + $this->assertEquals(1, $found->id); + $this->assertSame(2, (int) $this->scalar('SELECT COUNT(*) FROM topics'), 'nothing was created'); + + // "sql" exists but is linked to another article, so it is created and linked here + $created = $article->topics()->firstOrCreate(['name' => 'sql']); + $this->assertNotEquals(2, $created->id); + $this->assertSame(1, (int) $this->scalar("SELECT COUNT(*) FROM article_topic WHERE article_id = 1 AND topic_id = {$created->id}")); + $this->assertSame(2, (int) $this->scalar("SELECT COUNT(*) FROM topics WHERE name = 'sql'")); + } + + public function testUpdateOrCreateUpdatesAParentsRecordOrCreatesAndLinksOne(): void + { + $article = Article::find(1); + + $article->topics()->updateOrCreate(['name' => 'php'], ['name' => 'php 8']); + $this->assertSame('php 8', $this->scalar('SELECT name FROM topics WHERE id = 1')); + $this->assertSame(2, (int) $this->scalar('SELECT COUNT(*) FROM topics')); + + $new = $article->topics()->updateOrCreate(['name' => 'rust'], ['name' => 'rust']); + $this->assertSame(1, (int) $this->scalar("SELECT COUNT(*) FROM article_topic WHERE article_id = 1 AND topic_id = {$new->id}")); + } + + // ------------------------------------------------------------ hooks and transactions + + public function testHooksRunAndCanBeSkipped(): void + { + Topic::$created = 0; + $article = Article::find(1); + + $article->topics()->create(['name' => 'loud']); + $this->assertSame(1, Topic::$created); + + $article->topics()->withoutHook()->create(['name' => 'quiet']); + $this->assertSame(1, Topic::$created); + + $stored = new Topic(); + $stored->name = 'quietly stored'; + $article->topics()->withoutHook()->store($stored); + $this->assertSame(1, Topic::$created); + + $this->assertSame(['loud', 'php', 'quiet', 'quietly stored'], $this->names($article->topics()->get())); + } + + public function testAnOuterTransactionCanRollEverythingBack(): void + { + $db = new Database('default'); + + try { + $db->transaction(function () { + Article::find(1)->topics()->create(['name' => 'inside']); + Article::find(1)->topics()->createMany([['name' => 'more']]); + + throw new \RuntimeException('abort'); + }); + $this->fail('Expected the exception'); + } catch (\RuntimeException $e) { + $this->assertSame('abort', $e->getMessage()); + } + + $this->assertSame(2, (int) $this->scalar('SELECT COUNT(*) FROM topics')); + $this->assertSame(2, (int) $this->scalar('SELECT COUNT(*) FROM article_topic')); + $this->assertNoTransactionLeft(); + } + + public function testAnOuterTransactionKeepsTheWritesWhenItCommits(): void + { + (new Database('default'))->transaction(function () { + Article::find(1)->topics()->create(['name' => 'kept']); + }); + + $this->assertSame(['kept', 'php'], $this->names(Article::find(1)->topics()->get())); + $this->assertNoTransactionLeft(); + } + + public function testRelationQueriesStillWork(): void + { + $query = Article::find(1)->topics(); + + $this->assertInstanceOf(BindToMany::class, $query); + $this->assertSame(['php'], $this->names($query->get())); + $this->assertSame(1, $query->count()); + } +} + +class Article extends Model +{ + protected $table = 'articles'; + protected $connection = 'default'; + protected $timeStamps = false; + protected $creatable = ['title']; + + public function topics(): BindToMany + { + return $this->bindToMany(Topic::class); + } + + public function explicitTopics(): BindToMany + { + return $this->bindToMany(Topic::class, 'article_id', 'topic_id', 'article_topic'); + } + + public function lockedTopics(): BindToMany + { + return $this->bindToMany(LockedTopic::class, 'article_id', 'topic_id', 'article_topic'); + } +} + +class Topic extends Model +{ + public static int $created = 0; + + protected $table = 'topics'; + protected $connection = 'default'; + protected $timeStamps = false; + protected $creatable = ['name']; + + protected $hooks = [ + 'before_created' => [self::class, 'rejectBad'], + 'after_created' => [self::class, 'countCreated'], + ]; + + public static function rejectBad(Model $model): void + { + if ($model->name === 'bad') { + throw new \RuntimeException('bad topic'); + } + } + + public static function countCreated(Model $model): void + { + self::$created++; + } + + public function articles(): BindToMany + { + return $this->bindToMany(Article::class); + } +} + +/** + * Has no $creatable, so it cannot be saved. + */ +class LockedTopic extends Model +{ + protected $table = 'topics'; + protected $connection = 'default'; + protected $timeStamps = false; +} diff --git a/tests/Model/Relations/RelationManyToManyWritesMysqlTest.php b/tests/Model/Relations/RelationManyToManyWritesMysqlTest.php new file mode 100644 index 00000000..de78f7ad --- /dev/null +++ b/tests/Model/Relations/RelationManyToManyWritesMysqlTest.php @@ -0,0 +1,17 @@ + [ + ['name' => 'id', 'type' => 'id'], + ['name' => 'name', 'type' => 'string'], + ], + 'articles' => [ + ['name' => 'id', 'type' => 'id'], + ['name' => 'author_id', 'type' => 'integer', 'nullable' => true], + ['name' => 'title', 'type' => 'string'], + ['name' => 'views', 'type' => 'integer', 'nullable' => true, 'default' => 0], + ['name' => 'created_at', 'type' => 'datetime', 'nullable' => true], + ['name' => 'updated_at', 'type' => 'datetime', 'nullable' => true], + ['name' => 'deleted_at', 'type' => 'datetime', 'nullable' => true], + ], + 'labels' => [ + ['name' => 'id', 'type' => 'id'], + ['name' => 'name', 'type' => 'string'], + ['name' => 'deleted_at', 'type' => 'datetime', 'nullable' => true], + ], + 'article_label' => [ + ['name' => 'article_id', 'type' => 'integer', 'nullable' => true], + ['name' => 'label_id', 'type' => 'integer', 'nullable' => true], + ], + ]; + } + + protected function seedData(): array + { + $stamp = '2024-01-01 00:00:00'; + + return [ + 'authors' => [['name' => 'Ann'], ['name' => 'Ben'], ['name' => 'Cid']], + 'articles' => [ + ['author_id' => 1, 'title' => 'Alpha', 'views' => 10, 'created_at' => $stamp, 'updated_at' => $stamp], + ['author_id' => 1, 'title' => 'Beta', 'views' => 20, 'created_at' => $stamp, 'updated_at' => $stamp], + ['author_id' => 2, 'title' => 'Gamma', 'views' => 30, 'created_at' => $stamp, 'updated_at' => $stamp], + ['author_id' => 2, 'title' => 'Delta', 'views' => 40, 'created_at' => $stamp, 'updated_at' => $stamp], + ], + 'labels' => [['name' => 'php'], ['name' => 'sql'], ['name' => 'legacy']], + // article 1: php, sql | article 2: legacy | article 3: legacy | article 4: none + 'article_label' => [ + ['article_id' => 1, 'label_id' => 1], + ['article_id' => 1, 'label_id' => 2], + ['article_id' => 2, 'label_id' => 3], + ['article_id' => 3, 'label_id' => 3], + ], + ]; + } + + private function names(iterable $models, string $column = 'name'): array + { + $names = []; + foreach ($models as $model) { + $names[] = $model->{$column}; + } + sort($names); + + return $names; + } + + private function scalar(string $sql) + { + return $this->pdo->query($sql)->fetchColumn(); + } + + // ------------------------------------------------------------ many to many reads + + public function testManyToManyRelationMethodOnlyReturnsTheParentsRecords(): void + { + $this->assertSame(['php', 'sql'], $this->names(MockSoftArticle::find(1)->labels()->get())); + $this->assertSame(['legacy'], $this->names(MockSoftArticle::find(2)->labels()->get())); + $this->assertSame([], $this->names(MockSoftArticle::find(4)->labels()->get())); + } + + public function testManyToManyRelationMethodCountsAndChecksOnlyTheParentsRecords(): void + { + $this->assertSame(2, MockSoftArticle::find(1)->labels()->count()); + $this->assertSame(1, MockSoftArticle::find(3)->labels()->count()); + $this->assertSame(0, MockSoftArticle::find(4)->labels()->count()); + $this->assertTrue(MockSoftArticle::find(1)->labels()->exists()); + $this->assertFalse(MockSoftArticle::find(4)->labels()->exists()); + } + + public function testManyToManyConditionsStayInsideTheParent(): void + { + $article = MockSoftArticle::find(1); + + $this->assertSame(['php'], $this->names($article->labels()->where('name', 'php')->get())); + $this->assertSame([], $this->names($article->labels()->where('name', 'legacy')->get())); + $this->assertSame(['sql'], $this->names($article->labels()->where('name', '!=', 'php')->orderBy('id')->get())); + $this->assertSame('php', $article->labels()->orderBy('id')->first()->name); + $this->assertNull($article->labels()->where('name', 'legacy')->first()); + } + + public function testOrWhereCannotReachRecordsOfOtherParents(): void + { + $article = MockSoftArticle::find(1); + + $this->assertSame( + ['php'], + $this->names($article->labels()->where('name', 'php')->orWhere('name', 'legacy')->get()) + ); + $this->assertSame( + ['php'], + $this->names($article->labels()->orWhere('name', 'php')->orWhere('name', 'legacy')->get()) + ); + $this->assertSame( + ['php'], + $this->names($article->labels()->where(fn($group) => $group->where('name', 'php')->orWhere('name', 'legacy'))->get()) + ); + $this->assertSame( + ['php', 'sql'], + $this->names($article->labels()->whereIn('name', ['php', 'sql', 'legacy'])->get()) + ); + } + + public function testOrWhereCannotReachRecordsOfOtherParentsOnOneToMany(): void + { + $author = MockSoftAuthor::find(1); + + $this->assertSame( + ['Alpha'], + $this->names($author->articles()->where('title', 'Alpha')->orWhere('title', 'Gamma')->get(), 'title') + ); + $this->assertSame( + ['Alpha', 'Beta'], + $this->names($author->articles()->orWhere('title', 'Gamma')->orWhereIn('title', ['Alpha', 'Beta', 'Delta'])->get(), 'title') + ); + } + + public function testOrWhereCannotReachRecordsOfOtherParentsOnTheInverse(): void + { + $article = MockSoftArticle::find(3); // belongs to Ben + + $this->assertNull($article->author()->where('name', 'Ann')->first()); + $this->assertSame('Ben', $article->author()->where('name', 'Ann')->orWhere('name', 'Ben')->first()->name); + $this->assertNull($article->author()->where('name', 'Ann')->orWhere('name', 'Cid')->first()); + } + + public function testDuplicatePivotRowsDoNotDuplicateRecords(): void + { + $this->pdo->exec('INSERT INTO article_label (article_id, label_id) VALUES (1, 1)'); + + $this->assertSame(['php', 'sql'], $this->names(MockSoftArticle::find(1)->labels()->get())); + $this->assertSame(2, MockSoftArticle::find(1)->labels()->count()); + } + + // ------------------------------------------------------------ soft deletes and the scope together + + public function testSoftDeletedRelatedRecordsAreStillLeftOut(): void + { + $this->pdo->exec("UPDATE labels SET deleted_at = '2024-02-01 00:00:00' WHERE name = 'sql'"); + $this->pdo->exec("UPDATE articles SET deleted_at = '2024-02-01 00:00:00' WHERE title = 'Beta'"); + + $this->assertSame(['php'], $this->names(MockSoftArticle::find(1)->labels()->get())); + $this->assertSame(['php', 'sql'], $this->names(MockSoftArticle::find(1)->labels()->withTrashed()->get())); + $this->assertSame(['Alpha'], $this->names(MockSoftAuthor::find(1)->articles()->get(), 'title')); + $this->assertSame(['Alpha', 'Beta'], $this->names(MockSoftAuthor::find(1)->articles()->withTrashed()->get(), 'title')); + $this->assertSame(['Beta'], $this->names(MockSoftAuthor::find(1)->articles()->onlyTrashed()->get(), 'title')); + } + + // ------------------------------------------------------------ writes through the relation method + + public function testBuilderWritesThroughARelationOnlyTouchTheParentsRecords(): void + { + MockSoftAuthor::find(1)->articles()->update(['views' => 99]); + + $this->assertSame(2, (int) $this->scalar('SELECT COUNT(*) FROM articles WHERE views = 99 AND author_id = 1')); + $this->assertSame(0, (int) $this->scalar('SELECT COUNT(*) FROM articles WHERE views = 99 AND author_id = 2')); + + MockSoftAuthor::find(2)->articles()->delete(); // soft delete + + $this->assertSame(2, (int) $this->scalar('SELECT COUNT(*) FROM articles WHERE deleted_at IS NOT NULL AND author_id = 2')); + $this->assertSame(0, (int) $this->scalar('SELECT COUNT(*) FROM articles WHERE deleted_at IS NOT NULL AND author_id = 1')); + } + + public function testManyToManyBuilderUpdateOnlyTouchesTheParentsRecords(): void + { + MockSoftArticle::find(2)->labels()->update(['name' => 'old']); // article 2 has only "legacy" + + $this->assertSame(1, (int) $this->scalar("SELECT COUNT(*) FROM labels WHERE name = 'old'")); + $this->assertSame('old', $this->scalar('SELECT name FROM labels WHERE id = 3')); + $this->assertSame('php', $this->scalar('SELECT name FROM labels WHERE id = 1')); + } + + public function testLinkUnlinkAndRelateAreReflectedByTheRelationMethod(): void + { + $article = MockSoftArticle::find(4); + + $this->assertSame([], $this->names($article->labels()->get())); + + $article->labels()->link([3]); + $this->assertSame(['legacy'], $this->names($article->labels()->get())); + + $article->labels()->relate([1, 2]); + $this->assertSame(['php', 'sql'], $this->names($article->labels()->get())); + + $article->labels()->unlink(); + $this->assertSame(0, $article->labels()->count()); + $this->assertSame(['php', 'sql'], $this->names(MockSoftArticle::find(1)->labels()->get()), 'other articles are untouched'); + } + + // ------------------------------------------------------------ the rest still works + + public function testPropertyAccessAndEagerLoadingStillLoadPivotData(): void + { + $labels = MockSoftArticle::find(1)->labels; + + $this->assertSame(['php', 'sql'], $this->names($labels)); + $this->assertSame(1, (int) $labels[0]->pivot->article_id); + + $articles = MockSoftArticle::embed('labels')->orderBy('id')->get(); + $this->assertSame(['php', 'sql'], $this->names($articles[0]->labels)); + $this->assertSame(['legacy'], $this->names($articles[1]->labels)); + } + + public function testRelationsOfAnUnsavedModelMatchNothing(): void + { + $this->assertSame([], $this->names((new MockSoftArticle())->labels()->get())); + $this->assertSame(0, (new MockSoftArticle())->labels()->count()); + $this->assertSame([], $this->names((new MockSoftAuthor())->articles()->get(), 'title')); + } + + public function testRelationQueriesCanBeCloned(): void + { + $query = MockSoftArticle::find(1)->labels()->where('name', 'php'); + $copy = clone $query; + + $this->assertSame(['php'], $this->names($copy->get())); + $this->assertSame(['php', 'sql'], $this->names((clone $query)->orWhere('name', 'sql')->get())); + $this->assertSame(['php'], $this->names($query->get()), 'the original is not changed by its clones'); + } +} diff --git a/tests/Model/Relations/RelationScopeMysqlTest.php b/tests/Model/Relations/RelationScopeMysqlTest.php new file mode 100644 index 00000000..d4581f52 --- /dev/null +++ b/tests/Model/Relations/RelationScopeMysqlTest.php @@ -0,0 +1,17 @@ + [ + ['name' => 'id', 'type' => 'id'], + ['name' => 'name', 'type' => 'string'], + ], + 'bios' => [ + ['name' => 'id', 'type' => 'id'], + ['name' => 'author_id', 'type' => 'integer', 'nullable' => true], + ['name' => 'line', 'type' => 'string'], + ], + 'articles' => [ + ['name' => 'id', 'type' => 'id'], + ['name' => 'author_id', 'type' => 'integer', 'nullable' => true], + ['name' => 'title', 'type' => 'string'], + ], + 'notes' => [ + ['name' => 'id', 'type' => 'id'], + ['name' => 'author_id', 'type' => 'integer', 'nullable' => true], + ['name' => 'body', 'type' => 'string'], + ], + ]; + } + + protected function seedData(): array + { + return [ + 'authors' => [['name' => 'Ann'], ['name' => 'Ben']], + 'bios' => [], + 'articles' => [ + ['author_id' => 1, 'title' => 'Alpha'], + ['author_id' => 2, 'title' => 'Gamma'], + ], + 'notes' => [], + ]; + } + + private function rowCount(string $table, string $where = '1=1'): int + { + return (int) $this->pdo->query("SELECT COUNT(*) FROM {$table} WHERE {$where}")->fetchColumn(); + } + + public function testCreateThroughLinkManySetsTheForeignKey(): void + { + $author = Author::find(1); + + $article = $author->articles()->create(['title' => 'Beta']); + + $this->assertInstanceOf(Article::class, $article); + $this->assertNotNull($article->id); + $this->assertEquals(1, $article->author_id); + $this->assertSame(1, $this->rowCount('articles', "author_id = 1 AND title = 'Beta'")); + $this->assertSame(['Alpha', 'Beta'], $author->articles()->orderBy('id')->get()->pluck('title')->all()); + } + + public function testTheParentKeyWinsOverAForeignKeyInTheAttributes(): void + { + $article = Author::find(1)->articles()->create(['title' => 'Beta', 'author_id' => 2]); + + $this->assertEquals(1, $article->author_id); + $this->assertSame(1, $this->rowCount('articles', "title = 'Beta' AND author_id = 1")); + } + + public function testCreateThroughLinkOne(): void + { + $bio = Author::find(1)->bio()->create(['line' => 'Ann writes']); + + $this->assertInstanceOf(Bio::class, $bio); + $this->assertSame(1, $this->rowCount('bios', "author_id = 1 AND line = 'Ann writes'")); + $this->assertSame('Ann writes', Author::find(1)->bio->line); + } + + public function testCreateManyReturnsACollectionOfSavedModels(): void + { + $articles = Author::find(2)->articles()->createMany([ + ['title' => 'Delta'], + ['title' => 'Epsilon'], + ]); + + $this->assertInstanceOf(Collection::class, $articles); + $this->assertCount(2, $articles); + $this->assertSame(['Delta', 'Epsilon'], $articles->pluck('title')->all()); + $this->assertSame(3, $this->rowCount('articles', 'author_id = 2')); + } + + public function testStoreSavesANewModelAndMovesAnExistingOne(): void + { + $author = Author::find(1); + + $new = new Article(); + $new->title = 'Beta'; + + $this->assertSame($new, $author->articles()->store($new)); + $this->assertSame(1, $this->rowCount('articles', "title = 'Beta' AND author_id = 1")); + + $gamma = Article::find(2); // belongs to author 2 + $author->articles()->store($gamma); + + $this->assertSame(1, $this->rowCount('articles', "title = 'Gamma' AND author_id = 1")); + } + + public function testStoreManySavesEachModel(): void + { + $first = new Article(); + $first->title = 'One'; + $second = new Article(); + $second->title = 'Two'; + + $saved = Author::find(1)->articles()->storeMany([$first, $second]); + + $this->assertSame([$first, $second], $saved); + $this->assertSame(3, $this->rowCount('articles', 'author_id = 1')); + } + + public function testStoreRejectsAModelOfTheWrongClass(): void + { + $this->expectException(\InvalidArgumentException::class); + + Author::find(1)->articles()->store(new Bio()); + } + + public function testWritingThroughARelationOfAnUnsavedModelThrows(): void + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('unsaved'); + + (new Author())->articles()->create(['title' => 'Orphan']); + } + + public function testTheForeignKeyMustBeCreatable(): void + { + try { + Author::find(1)->notes()->create(['body' => 'secret']); + $this->fail('Expected a LogicException'); + } catch (\LogicException $e) { + $this->assertStringContainsString('author_id', $e->getMessage()); + $this->assertStringContainsString('$creatable', $e->getMessage()); + } + + $this->assertSame(0, $this->rowCount('notes')); + } + + public function testFirstOrCreateAndUpdateOrCreateStayInsideTheRelation(): void + { + $author = Author::find(1); + + $existing = $author->articles()->firstOrCreate(['title' => 'Alpha']); + $this->assertEquals(1, $existing->id); + $this->assertSame(1, $this->rowCount('articles', 'author_id = 1')); + + $created = $author->articles()->firstOrCreate(['title' => 'Beta']); + $this->assertEquals(1, $created->author_id); + + // Gamma belongs to another author, so it is not found here and is created for author 1 + $author->articles()->firstOrCreate(['title' => 'Gamma']); + $this->assertSame(1, $this->rowCount('articles', "title = 'Gamma' AND author_id = 1")); + $this->assertSame(1, $this->rowCount('articles', "title = 'Gamma' AND author_id = 2")); + + $author->articles()->updateOrCreate(['title' => 'Alpha'], ['title' => 'Alpha 2']); + $this->assertSame(1, $this->rowCount('articles', "title = 'Alpha 2' AND author_id = 1")); + } + + public function testHooksRunForRelationWritesAndCanBeSkipped(): void + { + Article::$created = 0; + $author = Author::find(1); + + $author->articles()->create(['title' => 'Beta']); + $this->assertSame(1, Article::$created); + + $author->articles()->withoutHook()->create(['title' => 'Quiet']); + $this->assertSame(1, Article::$created); + + $quiet = new Article(); + $quiet->title = 'Stored quietly'; + $author->articles()->withoutHook()->store($quiet); + $this->assertSame(1, Article::$created); + } + + public function testAssociateAndDissociateChangeTheChildUntilItIsSaved(): void + { + $article = Article::find(1); // author 1 + $ben = Author::find(2); + + $same = $article->author()->associate($ben); + + $this->assertSame($article, $same); + $this->assertEquals(2, $article->author_id); + $this->assertSame(1, $this->rowCount('articles', 'id = 1 AND author_id = 1'), 'nothing is saved yet'); + + $article->save(); + $this->assertSame(1, $this->rowCount('articles', 'id = 1 AND author_id = 2')); + $this->assertSame('Ben', Article::find(1)->author->name); + + $article->author()->associate(1); // a bare id works too + $article->save(); + $this->assertSame(1, $this->rowCount('articles', 'id = 1 AND author_id = 1')); + + $article->author()->dissociate(); + $article->save(); + $this->assertSame(1, $this->rowCount('articles', 'id = 1 AND author_id IS NULL')); + $this->assertNull(Article::find(1)->author); + } + + public function testAssociateRejectsAnUnsavedParent(): void + { + $this->expectException(\LogicException::class); + + Article::find(1)->author()->associate(new Author()); + } + + public function testAssociateRejectsAModelOfTheWrongClass(): void + { + $bio = Author::find(1)->bio()->create(['line' => 'Ann writes']); // a saved model, but not an Author + $article = Article::find(2); // author 2 + + try { + $article->author()->associate($bio); + $this->fail('Expected an InvalidArgumentException'); + } catch (\InvalidArgumentException $e) { + $this->assertStringContainsString(Author::class, $e->getMessage()); + $this->assertStringContainsString(Bio::class, $e->getMessage()); + } + + $this->assertEquals(2, $article->author_id, 'the child is left unchanged'); + $article->save(); + $this->assertSame(1, $this->rowCount('articles', 'id = 2 AND author_id = 2')); + } + + public function testAssociateNeedsTheForeignKeyToBeCreatable(): void + { + $note = new Note(); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('$creatable'); + + $note->author()->associate(Author::find(1)); + } + + public function testRelationQueriesStillWorkNormally(): void + { + $author = Author::find(1); + + $this->assertInstanceOf(LinkMany::class, $author->articles()); + $this->assertInstanceOf(LinkOne::class, $author->bio()); + $this->assertInstanceOf(BindTo::class, Article::find(1)->author()); + $this->assertSame(['Alpha'], $author->articles()->get()->pluck('title')->all()); + } +} + +class Author extends Model +{ + protected $table = 'authors'; + protected $connection = 'default'; + protected $timeStamps = false; + protected $creatable = ['name']; + + public function articles(): LinkMany + { + return $this->linkMany(Article::class); + } + + public function bio(): LinkOne + { + return $this->linkOne(Bio::class); + } + + public function notes(): LinkMany + { + return $this->linkMany(Note::class); + } +} + +class Bio extends Model +{ + protected $table = 'bios'; + protected $connection = 'default'; + protected $timeStamps = false; + protected $creatable = ['author_id', 'line']; +} + +class Article extends Model +{ + public static int $created = 0; + + protected $table = 'articles'; + protected $connection = 'default'; + protected $timeStamps = false; + protected $creatable = ['author_id', 'title']; + + protected $hooks = [ + 'after_created' => [self::class, 'countCreated'], + ]; + + public static function countCreated(Model $model): void + { + self::$created++; + } + + public function author(): BindTo + { + return $this->bindTo(Author::class); + } +} + +/** + * The foreign key is left out of $creatable on purpose. + */ +class Note extends Model +{ + protected $table = 'notes'; + protected $connection = 'default'; + protected $timeStamps = false; + protected $creatable = ['body']; + + public function author(): BindTo + { + return $this->bindTo(Author::class); + } +} diff --git a/tests/Model/Relations/RelationWritesMysqlTest.php b/tests/Model/Relations/RelationWritesMysqlTest.php new file mode 100644 index 00000000..3a350d1b --- /dev/null +++ b/tests/Model/Relations/RelationWritesMysqlTest.php @@ -0,0 +1,17 @@ +