Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat (Auditable): customized relationship for Attach, Detach and Sync #921

Merged
merged 6 commits into from
May 28, 2024
Merged
83 changes: 59 additions & 24 deletions src/Auditable.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace OwenIt\Auditing;

use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Arr;
Expand Down Expand Up @@ -702,18 +703,24 @@ public function transitionTo(Contracts\Audit $audit, bool $old = false): Contrac
* @param array $attributes
* @param bool $touch
* @param array $columns
* @param \Closure|null $callback
* @return void
* @throws AuditingException
*/
public function auditAttach(string $relationName, $id, array $attributes = [], $touch = true, $columns = ['*'])
public function auditAttach(string $relationName, $id, array $attributes = [], $touch = true, $columns = ['*'], $callback = null)
{
if (!method_exists($this, $relationName) || !method_exists($this->{$relationName}(), 'attach')) {
throw new AuditingException('Relationship ' . $relationName . ' was not found or does not support method attach');
$this->validateRelationshipMethodExistence($relationName, 'attach');

$relationCall = $this->{$relationName}();

if ($callback instanceof \Closure) {
$this->applyClosureToRelationship($relationCall, $callback);
}

$old = $this->{$relationName}()->get($columns);
$this->{$relationName}()->attach($id, $attributes, $touch);
$new = $this->{$relationName}()->get($columns);
$old = $relationCall->get($columns);
$relationCall->attach($id, $attributes, $touch);
$new = $relationCall->get($columns);

$this->dispatchRelationAuditEvent($relationName, 'attach', $old, $new);
}

Expand All @@ -722,44 +729,57 @@ public function auditAttach(string $relationName, $id, array $attributes = [], $
* @param mixed $ids
* @param bool $touch
* @param array $columns
* @param \Closure|null $callback
* @return int
* @throws AuditingException
*/
public function auditDetach(string $relationName, $ids = null, $touch = true, $columns = ['*'])
public function auditDetach(string $relationName, $ids = null, $touch = true, $columns = ['*'], $callback = null)
{
if (!method_exists($this, $relationName) || !method_exists($this->{$relationName}(), 'detach')) {
throw new AuditingException('Relationship ' . $relationName . ' was not found or does not support method detach');
$this->validateRelationshipMethodExistence($relationName, 'detach');

$relationCall = $this->{$relationName}();

if ($callback instanceof \Closure) {
$this->applyClosureToRelationship($relationCall, $callback);
}

$old = $this->{$relationName}()->get($columns);
$results = $this->{$relationName}()->detach($ids, $touch);
$new = $this->{$relationName}()->get($columns);
$old = $relationCall->get($columns);
$results = $relationCall->detach($ids, $touch);
$new = $relationCall->get($columns);

$this->dispatchRelationAuditEvent($relationName, 'detach', $old, $new);

return empty($results) ? 0 : $results;
}

/**
* @param $relationName
* @param string $relationName
* @param \Illuminate\Support\Collection|\Illuminate\Database\Eloquent\Model|array $ids
* @param bool $detaching
* @param array $columns
* @param \Closure|null $callback
* @return array
* @throws AuditingException
*/
public function auditSync($relationName, $ids, $detaching = true, $columns = ['*'])
public function auditSync(string $relationName, $ids, $detaching = true, $columns = ['*'], $callback = null)
{
if (!method_exists($this, $relationName) || !method_exists($this->{$relationName}(), 'sync')) {
throw new AuditingException('Relationship ' . $relationName . ' was not found or does not support method sync');
$this->validateRelationshipMethodExistence($relationName, 'sync');

$relationCall = $this->{$relationName}();

if ($callback instanceof \Closure) {
$this->applyClosureToRelationship($relationCall, $callback);
}

$old = $this->{$relationName}()->get($columns);
$changes = $this->{$relationName}()->sync($ids, $detaching);
$old = $relationCall->get($columns);
$changes = $relationCall->sync($ids, $detaching);

if (collect($changes)->flatten()->isEmpty()) {
$old = $new = collect([]);
} else {
$new = $this->{$relationName}()->get($columns);
$new = $relationCall->get($columns);
}

$this->dispatchRelationAuditEvent($relationName, 'sync', $old, $new);

return $changes;
Expand All @@ -769,16 +789,15 @@ public function auditSync($relationName, $ids, $detaching = true, $columns = ['*
* @param string $relationName
* @param \Illuminate\Support\Collection|\Illuminate\Database\Eloquent\Model|array $ids
* @param array $columns
* @param \Closure|null $callback
* @return array
* @throws AuditingException
*/
public function auditSyncWithoutDetaching(string $relationName, $ids, $columns = ['*'])
public function auditSyncWithoutDetaching(string $relationName, $ids, $columns = ['*'], $callback = null)
{
if (!method_exists($this, $relationName) || !method_exists($this->{$relationName}(), 'syncWithoutDetaching')) {
throw new AuditingException('Relationship ' . $relationName . ' was not found or does not support method syncWithoutDetaching');
}
$this->validateRelationshipMethodExistence($relationName, 'syncWithoutDetaching');

return $this->auditSync($relationName, $ids, false, $columns);
return $this->auditSync($relationName, $ids, false, $columns, $callback);
}

/**
Expand All @@ -805,4 +824,20 @@ private function dispatchRelationAuditEvent($relationName, $event, $old, $new)
Event::dispatch(AuditCustom::class, [$this]);
$this->isCustomEvent = false;
}

private function validateRelationshipMethodExistence(string $relationName, string $methodName): void
{
if (!method_exists($this, $relationName) || !method_exists($this->{$relationName}(), $methodName)) {
throw new AuditingException("Relationship $relationName was not found or does not support method $methodName");
}
}

private function applyClosureToRelationship(BelongsToMany $relation, \Closure $closure): void
{
try {
$closure($relation);
} catch (\Throwable $exception) {
throw new AuditingException("Invalid Closure for {$relation->getRelationName()} Relationship");
}
}
}
149 changes: 149 additions & 0 deletions tests/Functional/AuditingTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace OwenIt\Auditing\Tests\Functional;

use Carbon\Carbon;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\Assert;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Support\Facades\App;
Expand Down Expand Up @@ -626,6 +627,20 @@ public function itWillAuditAttach()
$this->assertSame($secondCategory->name, $lastArticleAudit['new'][0]['name']);
}

/**
* @test
* @return void
*/
public function itWillNotAuditAttachByInvalidRelationName()
{
$firstCategory = factory(Category::class)->create();
$article = factory(Article::class)->create();

$this->expectExceptionMessage("Relationship invalidRelation was not found or does not support method attach");

$article->auditAttach('invalidRelation', $firstCategory);
}

/**
* @test
* @return void
Expand All @@ -652,6 +667,78 @@ public function itWillAuditSync()
$this->assertGreaterThan($no_of_audits_before, $no_of_audits_after);
}

/**
* @test
* @return void
*/
public function itWillAuditSyncByClosure()
{
$firstCategory = factory(Category::class)->create();
$secondCategory = factory(Category::class)->create();
$thirdCategory = factory(Category::class)->create();
$article = factory(Article::class)->create();

$article->categories()->attach([$firstCategory->getKey() => [ 'pivot_type' => 'PIVOT_1' ]]);
$article->categories()->attach([$secondCategory->getKey() => [ 'pivot_type' => 'PIVOT_2' ]]);

$no_of_audits_before = Audit::where('auditable_type', Article::class)->count();
$categoryBefore = $article->categories()->first()->getKey();

$article->auditSync(
'categories',
[$thirdCategory->getKey() => [ 'pivot_type' => 'PIVOT_1' ]],
true,
['*'],
function ($categories) { return $categories->wherePivot('pivot_type', 'PIVOT_1'); }
);

$no_of_audits_after = Audit::where('auditable_type', Article::class)->count();
$categoryAfter = $article->categories()->first()->getKey();

$this->assertSame($firstCategory->getKey(), $categoryBefore);
$this->assertSame($secondCategory->getKey(), $categoryAfter);
$this->assertNotSame($categoryBefore, $categoryAfter);
$this->assertGreaterThan($no_of_audits_before, $no_of_audits_after);

$this->assertSame(
"{$secondCategory->getKey()},{$thirdCategory->getKey()}",
$article->categories()->pluck('id')->join(',')
);

$this->assertSame(
$secondCategory->getKey(),
$article->categories()->wherePivot('pivot_type', 'PIVOT_2')->first()->getKey()
);

$this->assertSame(
$thirdCategory->getKey(),
$article->categories()->wherePivot('pivot_type', 'PIVOT_1')->first()->getKey()
);
}

/**
* @test
* @return void
*/
public function itWillNotAuditSyncByInvalidClosure()
{
$firstCategory = factory(Category::class)->create();
$secondCategory = factory(Category::class)->create();
$article = factory(Article::class)->create();

$article->categories()->attach($firstCategory);

$this->expectException(QueryException::class);

$article->auditSync(
'categories',
[$secondCategory->getKey()],
true,
['*'],
function ($categories) { return $categories->wherePivot('invalid_pivot_column', 'PIVOT_1'); }
);
}

/**
* @test
* @return void
Expand Down Expand Up @@ -679,6 +766,68 @@ public function itWillAuditDetach()
$this->assertGreaterThan($no_of_audits_before, $no_of_audits_after);
}

/**
* @test
* @return void
*/
public function itWillAuditDetachByClosure()
{
$firstCategory = factory(Category::class)->create();
$secondCategory = factory(Category::class)->create();
$thirdCategory = factory(Category::class)->create();
$article = factory(Article::class)->create();

$article->categories()->attach([$firstCategory->getKey() => [ 'pivot_type' => 'PIVOT_1' ]]);
$article->categories()->attach([$secondCategory->getKey() => [ 'pivot_type' => 'PIVOT_2' ]]);
$article->categories()->attach([$thirdCategory->getKey() => [ 'pivot_type' => 'PIVOT_2' ]]);

$no_of_audits_before = Audit::where('auditable_type', Article::class)->count();
$categoryBefore = $article->categories()->first()->getKey();

$article->auditDetach(
'categories',
[$firstCategory->getKey(), $secondCategory->getKey(), $thirdCategory->getKey()],
true,
['*'],
function ($categories) { return $categories->wherePivot('pivot_type', 'PIVOT_1'); }
);

$no_of_audits_after = Audit::where('auditable_type', Article::class)->count();
$categoryAfter = $article->categories()->first()->getKey();

$this->assertSame($firstCategory->getKey(), $categoryBefore);
$this->assertSame($secondCategory->getKey(), $categoryAfter);
$this->assertNotSame($categoryBefore, $categoryAfter);
$this->assertGreaterThan($no_of_audits_before, $no_of_audits_after);

$this->assertSame(
"{$secondCategory->getKey()},{$thirdCategory->getKey()}",
$article->categories()->pluck('id')->join(',')
);
}

/**
* @test
* @return void
*/
public function itWillNotAuditDetachByInvalidClosure()
{
$firstCategory = factory(Category::class)->create();
$article = factory(Article::class)->create();

$article->categories()->attach($firstCategory);

$this->expectExceptionMessage('Invalid Closure for categories Relationship');

$article->auditDetach(
'categories',
[$firstCategory->getKey()],
true,
['*'],
function ($categories) { return $categories->invalid(); }
);
}

/**
* @test
* @return void
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public function up()

Schema::create('model_has_categories', function (Blueprint $table) {
$table->string('model_type');
$table->string('pivot_type')->nullable();
$table->unsignedBigInteger('category_id');
$table->unsignedBigInteger('model_id');
$table->timestamps();
Expand Down