diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index de05a3e6..6a95e513 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -13,17 +13,13 @@ jobs: strategy: fail-fast: false matrix: - php: ["8.0", "7.4", "7.3"] - laravel: [8.*, 7.*, 6.*] + php: ["8.0"] + laravel: [8.*] dependency-version: [prefer-lowest, prefer-stable] os: [ubuntu-latest, windows-latest] include: - laravel: 8.* testbench: 6.* - - laravel: 7.* - testbench: 5.* - - laravel: 6.* - testbench: 4.* name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.dependency-version }} - ${{ matrix.os }} diff --git a/composer.json b/composer.json index c9e57af3..bc47f87f 100644 --- a/composer.json +++ b/composer.json @@ -31,14 +31,15 @@ } ], "require": { - "php": "^7.3 || ^8.0", - "illuminate/config": "^6.0 || ^7.0 || ^8.0", - "illuminate/database": "^6.0 || ^7.0 || ^8.0", - "illuminate/support": "^6.0 || ^7.0 || ^8.0" + "php": "^8.0", + "illuminate/config": "^8.0", + "illuminate/database": "^8.0", + "illuminate/support": "^8.0", + "spatie/laravel-package-tools": "^1.6" }, "require-dev": { "ext-json": "*", - "orchestra/testbench": "^4.0 || ^5.0 || ^6.0", + "orchestra/testbench": "^6.0", "phpunit/phpunit": "^9.3" }, "config": { diff --git a/migrations/add_batch_uuid_column_to_activity_log_table.php.stub b/migrations/add_batch_uuid_column_to_activity_log_table.php.stub new file mode 100644 index 00000000..90afe652 --- /dev/null +++ b/migrations/add_batch_uuid_column_to_activity_log_table.php.stub @@ -0,0 +1,28 @@ +table(config('activitylog.table_name'), function (Blueprint $table) { + $table->uuid('batch_uuid')->nullable()->after('properties'); + }); + } + + /** + * Reverse the migrations. + */ + public function down() + { + Schema::connection(config('activitylog.database_connection'))->table(config('activitylog.table_name'), function (Blueprint $table) { + $table->dropColumn('batch_uuid'); + }); + } +} diff --git a/src/ActivityLogger.php b/src/ActivityLogger.php index d3d9394f..cec3cc31 100644 --- a/src/ActivityLogger.php +++ b/src/ActivityLogger.php @@ -2,7 +2,8 @@ namespace Spatie\Activitylog; -use Illuminate\Auth\AuthManager; +use Closure; +use DateTimeInterface; use Illuminate\Contracts\Config\Repository; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Arr; @@ -10,75 +11,70 @@ use Illuminate\Support\Str; use Illuminate\Support\Traits\Macroable; use Spatie\Activitylog\Contracts\Activity as ActivityContract; -use Spatie\Activitylog\Exceptions\CouldNotLogActivity; class ActivityLogger { use Macroable; - /** @var \Illuminate\Auth\AuthManager */ - protected $auth; + protected ?string $defaultLogName = null; - protected $defaultLogName = ''; + protected CauserResolver $causerResolver; - /** @var string */ - protected $authDriver; + protected ActivityLogStatus $logStatus; - /** @var \Spatie\Activitylog\ActivityLogStatus */ - protected $logStatus; + protected ?ActivityContract $activity = null; - /** @var \Spatie\Activitylog\Contracts\Activity */ - protected $activity; + protected LogBatch $batch; - public function __construct(AuthManager $auth, Repository $config, ActivityLogStatus $logStatus) + public function __construct(Repository $config, ActivityLogStatus $logStatus, LogBatch $batch, CauserResolver $causerResolver) { - $this->auth = $auth; + $this->causerResolver = $causerResolver; - $this->authDriver = $config['activitylog']['default_auth_driver'] ?? $auth->getDefaultDriver(); + $this->batch = $batch; $this->defaultLogName = $config['activitylog']['default_log_name']; $this->logStatus = $logStatus; } - public function setLogStatus(ActivityLogStatus $logStatus) + public function setLogStatus(ActivityLogStatus $logStatus): static { $this->logStatus = $logStatus; return $this; } - public function performedOn(Model $model) + public function performedOn(Model $model): static { $this->getActivity()->subject()->associate($model); return $this; } - public function on(Model $model) + public function on(Model $model): static { return $this->performedOn($model); } - public function causedBy($modelOrId) + public function causedBy(Model | int | string | null $modelOrId): static { if ($modelOrId === null) { return $this; } - $model = $this->normalizeCauser($modelOrId); + $model = $this->causerResolver->resolve($modelOrId); $this->getActivity()->causer()->associate($model); return $this; } - public function by($modelOrId) + public function by(Model | int | string | null $modelOrId): static { return $this->causedBy($modelOrId); } - public function causedByAnonymous() + public function causedByAnonymous(): static { $this->activity->causer_id = null; $this->activity->causer_type = null; @@ -86,81 +82,81 @@ public function causedByAnonymous() return $this; } - public function byAnonymous() + public function byAnonymous(): static { return $this->causedByAnonymous(); } - public function event(string $event) + public function event(string $event): static { return $this->setEvent($event); } - public function setEvent(string $event) + public function setEvent(string $event): static { $this->activity->event = $event; return $this; } - public function withProperties($properties) + public function withProperties(mixed $properties): static { $this->getActivity()->properties = collect($properties); return $this; } - public function withProperty(string $key, $value) + public function withProperty(string $key, mixed $value): static { $this->getActivity()->properties = $this->getActivity()->properties->put($key, $value); return $this; } - public function createdAt(Carbon $dateTime) + public function createdAt(DateTimeInterface $dateTime): static { - $this->getActivity()->created_at = $dateTime; + $this->getActivity()->created_at = Carbon::instance($dateTime); return $this; } - public function useLog(string $logName) + public function useLog(string $logName): static { $this->getActivity()->log_name = $logName; return $this; } - public function inLog(string $logName) + public function inLog(string $logName): static { return $this->useLog($logName); } - public function tap(callable $callback, string $eventName = null) + public function tap(callable $callback, string $eventName = null): static { call_user_func($callback, $this->getActivity(), $eventName); return $this; } - public function enableLogging() + public function enableLogging(): static { $this->logStatus->enable(); return $this; } - public function disableLogging() + public function disableLogging(): static { $this->logStatus->disable(); return $this; } - public function log(string $description) + public function log(string $description): ?ActivityContract { if ($this->logStatus->disabled()) { - return; + return null; } $activity = $this->activity; @@ -177,7 +173,7 @@ public function log(string $description) return $activity; } - public function withoutLogs(callable $callback) + public function withoutLogs(Closure $callback): mixed { if ($this->logStatus->disabled()) { return $callback(); @@ -192,23 +188,6 @@ public function withoutLogs(callable $callback) } } - protected function normalizeCauser($modelOrId): Model - { - if ($modelOrId instanceof Model) { - return $modelOrId; - } - - $guard = $this->auth->guard($this->authDriver); - $provider = method_exists($guard, 'getProvider') ? $guard->getProvider() : null; - $model = method_exists($provider, 'retrieveById') ? $provider->retrieveById($modelOrId) : null; - - if ($model instanceof Model) { - return $model; - } - - throw CouldNotLogActivity::couldNotDetermineUser($modelOrId); - } - protected function replacePlaceholders(string $description, ActivityContract $activity): string { return preg_replace_callback('/:[a-z0-9._-]+/i', function ($match) use ($activity) { @@ -241,7 +220,9 @@ protected function getActivity(): ActivityContract $this ->useLog($this->defaultLogName) ->withProperties([]) - ->causedBy($this->auth->guard($this->authDriver)->user()); + ->causedBy($this->causerResolver->resolve()); + + $this->activity->batch_uuid = $this->batch->getUuid(); } return $this->activity; diff --git a/src/ActivitylogServiceProvider.php b/src/ActivitylogServiceProvider.php index 1ea4979b..6138f415 100644 --- a/src/ActivitylogServiceProvider.php +++ b/src/ActivitylogServiceProvider.php @@ -3,30 +3,36 @@ namespace Spatie\Activitylog; use Illuminate\Database\Eloquent\Model; -use Illuminate\Support\ServiceProvider; -use Illuminate\Support\Str; use Spatie\Activitylog\Contracts\Activity; use Spatie\Activitylog\Contracts\Activity as ActivityContract; use Spatie\Activitylog\Exceptions\InvalidConfiguration; use Spatie\Activitylog\Models\Activity as ActivityModel; +use Spatie\LaravelPackageTools\Package; +use Spatie\LaravelPackageTools\PackageServiceProvider; -class ActivitylogServiceProvider extends ServiceProvider +class ActivitylogServiceProvider extends PackageServiceProvider { - public function boot() + public function configurePackage(Package $package): void { - $this->bootConfig(); - $this->bootMigrations(); + $package + ->name('laravel-activitylog') + ->hasConfigFile('activitylog') + ->hasMigrations([ + 'CreateActivityLogTable', + 'AddEventColumnToActivityLogTable', + 'AddBatchUuidColumnToActivityLogTable', + ]) + ->hasCommand(CleanActivitylogCommand::class); } - public function register() + + public function registeringPackage() { - $this->app->bind('command.activitylog:clean', CleanActivitylogCommand::class); + $this->app->bind(ActivityLogger::class); - $this->commands([ - 'command.activitylog:clean', - ]); + $this->app->singleton(LogBatch::class); - $this->app->bind(ActivityLogger::class); + $this->app->singleton(CauserResolver::class); $this->app->singleton(ActivityLogStatus::class); } @@ -49,31 +55,4 @@ public static function getActivityModelInstance(): ActivityContract return new $activityModelClassName(); } - - protected function bootConfig(): void - { - $this->publishes([ - __DIR__.'/../config/activitylog.php' => config_path('activitylog.php'), - ], 'config'); - - $this->mergeConfigFrom(__DIR__.'/../config/activitylog.php', 'activitylog'); - } - - protected function bootMigrations(): void - { - foreach ([ - 'CreateActivityLogTable', - 'AddEventColumnToActivityLogTable', - ] as $i => $migration) { - if (! class_exists($migration)) { - $this->publishes([ - __DIR__.'/../migrations/'.Str::snake($migration).'.php.stub' => database_path(sprintf( - '/migrations/%s_%s.php', - date('Y_m_d_His', time() + $i), - Str::snake($migration) - )), - ], 'migrations'); - } - } - } } diff --git a/src/CauserResolver.php b/src/CauserResolver.php new file mode 100644 index 00000000..83be9e8d --- /dev/null +++ b/src/CauserResolver.php @@ -0,0 +1,131 @@ +authManager = $authManager; + + $this->authDriver = $config['activitylog']['default_auth_driver'] ?? $this->authManager->getDefaultDriver(); + } + + /** + * Reslove causer based different arguments first we'll check for override closure + * Then check for the result causer if it valid. In other case will return the + * override causer defined by the user or delgate to the getCauser() method + * + * @param Model|int|null $subject + * @return null|Model + * @throws InvalidArgumentException + * @throws CouldNotLogActivity + */ + public function resolve(Model | int | string | null $subject = null) : ?Model + { + if ($this->causerOverride !== null) { + return $this->causerOverride; + } + + if ($this->resolverOverride !== null) { + $resultCauser = ($this->resolverOverride)($subject); + + if (! $this->isResolveable($resultCauser)) { + throw CouldNotLogActivity::couldNotDetermineUser($resultCauser); + } + + return $resultCauser; + } + + return $this->getCauser($subject); + } + + + /** + * Resolve the user based on passed id + * + * @param int $subject + * @return Model + * @throws InvalidArgumentException + * @throws CouldNotLogActivity + */ + protected function resolveUsingId(int | string $subject) : Model + { + $guard = $this->authManager->guard($this->authDriver); + + $provider = method_exists($guard, 'getProvider') ? $guard->getProvider() : null; + $model = method_exists($provider, 'retrieveById') ? $provider->retrieveById($subject) : null; + + + throw_unless($model instanceof Model, CouldNotLogActivity::couldNotDetermineUser($subject)); + + return $model; + } + + /** + * Return the subject if it was model. If the subject wasn't set will try to resolve + * current authenticated user using the auth manager, else resolve the causer + * from users table using id else throw couldNotDetermineUser exception + * + * @param Model|int|null $subject + * @return null|Model + * @throws InvalidArgumentException + * @throws CouldNotLogActivity + */ + protected function getCauser(Model | int | string | null $subject = null): ?Model + { + if ($subject instanceof Model) { + return $subject; + } + + if (is_null($subject)) { + return $this->getDefaultCauser(); + } + + return $this->resolveUsingId($subject); + } + + public function resolveUsing(Closure $callback): static + { + $this->resolverOverride = $callback; + + return $this; + } + + public function setCauser(?Model $causer): static + { + $this->causerOverride = $causer; + + return $this; + } + + protected function isResolveable(mixed $model): bool + { + return ($model instanceof Model || is_null($model)); + } + + protected function getDefaultCauser(): ?Model + { + return $this->authManager->guard($this->authDriver)->user(); + } +} diff --git a/src/Contracts/Activity.php b/src/Contracts/Activity.php index 2e187729..d2113d35 100644 --- a/src/Contracts/Activity.php +++ b/src/Contracts/Activity.php @@ -13,7 +13,7 @@ public function subject(): MorphTo; public function causer(): MorphTo; - public function getExtraProperty(string $propertyName); + public function getExtraProperty(string $propertyName): mixed; public function changes(): Collection; diff --git a/src/Contracts/LoggablePipe.php b/src/Contracts/LoggablePipe.php new file mode 100644 index 00000000..3fae39a4 --- /dev/null +++ b/src/Contracts/LoggablePipe.php @@ -0,0 +1,10 @@ +options ??= $model->getActivitylogOptions(); + } +} diff --git a/src/Facades/CauserResolver.php b/src/Facades/CauserResolver.php new file mode 100644 index 00000000..802189f0 --- /dev/null +++ b/src/Facades/CauserResolver.php @@ -0,0 +1,21 @@ +toString(); + } + + public function getUuid(): ?string + { + return $this->uuid; + } + + public function withinBatch(Closure $callback): mixed + { + $this->startBatch(); + $result = $callback(); + $this->endBatch(); + + return $result; + } + + public function startBatch(): void + { + if (! $this->isOpen()) { + $this->uuid = $this->generateUUID(); + } + + $this->transactions++; + } + + public function isOpen(): bool + { + return $this->transactions > 0; + } + + public function endBatch(): void + { + $this->transactions = max(0, $this->transactions - 1); + + if ($this->transactions === 0) { + $this->uuid = null; + } + } +} diff --git a/src/LogOptions.php b/src/LogOptions.php new file mode 100644 index 00000000..c19f0811 --- /dev/null +++ b/src/LogOptions.php @@ -0,0 +1,116 @@ +logOnly(['*']); + } + + public function logUnguarded(): self + { + $this->logUnguarded = true; + + return $this; + } + + public function logFillable(): self + { + $this->logFillable = true; + + return $this; + } + + public function dontLogFillable(): self + { + $this->logFillable = false; + + return $this; + } + + + public function logOnlyDirty(): self + { + $this->logOnlyDirty = true; + + return $this; + } + + public function logOnly(array $attributes): self + { + $this->logAttributes = $attributes; + + return $this; + } + + public function logExcept(array $attributes): self + { + $this->logExceptAttributes = $attributes; + + return $this; + } + + public function dontLogIfAttributesChangedOnly(array $attributes): self + { + $this->dontLogIfAttributesChangedOnly = $attributes; + + return $this; + } + + + public function dontSubmitEmptyLogs(): self + { + $this->submitEmptyLogs = false; + + return $this; + } + + public function submitEmptyLogs(): self + { + $this->submitEmptyLogs = true; + + return $this; + } + + public function useLogName(string $logname): self + { + $this->logName = $logname; + + return $this; + } + + + public function setDescriptionForEvent(Closure $callback): self + { + $this->descriptionForEvent = $callback; + + return $this; + } +} diff --git a/src/Models/Activity.php b/src/Models/Activity.php index 5ee4d0ab..092bd112 100644 --- a/src/Models/Activity.php +++ b/src/Models/Activity.php @@ -44,7 +44,7 @@ public function causer(): MorphTo return $this->morphTo(); } - public function getExtraProperty(string $propertyName) + public function getExtraProperty(string $propertyName): mixed { return Arr::get($this->properties->toArray(), $propertyName); } @@ -90,4 +90,14 @@ public function scopeForEvent(Builder $query, string $event): Builder { return $query->where('event', $event); } + + public function scopeHasBatch(Builder $query): Builder + { + return $query->whereNotNull('batch_uuid'); + } + + public function scopeForBatch(Builder $query, string $batchUuid): Builder + { + return $query->where('batch_uuid', $batchUuid); + } } diff --git a/src/Traits/DetectsChanges.php b/src/Traits/DetectsChanges.php deleted file mode 100644 index e392ce38..00000000 --- a/src/Traits/DetectsChanges.php +++ /dev/null @@ -1,219 +0,0 @@ -contains('updated')) { - static::updating(function (Model $model) { - - //temporary hold the original attributes on the model - //as we'll need these in the updating event - if (method_exists(Model::class, 'getRawOriginal')) { - // Laravel >7.0 - $oldValues = (new static)->setRawAttributes($model->getRawOriginal()); - } else { - // Laravel <7.0 - $oldValues = (new static)->setRawAttributes($model->getOriginal()); - } - - $model->oldAttributes = static::logChanges($oldValues); - }); - } - } - - public function attributesToBeLogged(): array - { - $attributes = []; - - if (isset(static::$logFillable) && static::$logFillable) { - $attributes = array_merge($attributes, $this->getFillable()); - } - - if ($this->shouldLogUnguarded()) { - $attributes = array_merge($attributes, array_diff(array_keys($this->getAttributes()), $this->getGuarded())); - } - - if (isset(static::$logAttributes) && is_array(static::$logAttributes)) { - $attributes = array_merge($attributes, array_diff(static::$logAttributes, ['*'])); - - if (in_array('*', static::$logAttributes)) { - $attributes = array_merge($attributes, array_keys($this->getAttributes())); - } - } - - if (isset(static::$logAttributesToIgnore) && is_array(static::$logAttributesToIgnore)) { - $attributes = array_diff($attributes, static::$logAttributesToIgnore); - } - - return $attributes; - } - - public function shouldLogOnlyDirty(): bool - { - if (! isset(static::$logOnlyDirty)) { - return false; - } - - return static::$logOnlyDirty; - } - - public function shouldLogUnguarded(): bool - { - if (! isset(static::$logUnguarded)) { - return false; - } - - if (! static::$logUnguarded) { - return false; - } - - if (in_array('*', $this->getGuarded())) { - return false; - } - - return true; - } - - public function attributeValuesToBeLogged(string $processingEvent): array - { - if (! count($this->attributesToBeLogged())) { - return []; - } - - $properties['attributes'] = static::logChanges( - $processingEvent == 'retrieved' - ? $this - : ( - $this->exists - ? $this->fresh() ?? $this - : $this - ) - ); - - if (static::eventsToBeRecorded()->contains('updated') && $processingEvent == 'updated') { - $nullProperties = array_fill_keys(array_keys($properties['attributes']), null); - - $properties['old'] = array_merge($nullProperties, $this->oldAttributes); - - $this->oldAttributes = []; - } - - if ($this->shouldLogOnlyDirty() && isset($properties['old'])) { - $properties['attributes'] = array_udiff_assoc( - $properties['attributes'], - $properties['old'], - function ($new, $old) { - if ($old === null || $new === null) { - return $new === $old ? 0 : 1; - } - - return $new <=> $old; - } - ); - $properties['old'] = collect($properties['old']) - ->only(array_keys($properties['attributes'])) - ->all(); - } - - if (static::eventsToBeRecorded()->contains('deleted') && $processingEvent == 'deleted') { - $properties['old'] = $properties['attributes']; - unset($properties['attributes']); - } - - return $properties; - } - - public static function logChanges(Model $model): array - { - $changes = []; - $attributes = $model->attributesToBeLogged(); - - foreach ($attributes as $attribute) { - if (Str::contains($attribute, '.')) { - $changes += self::getRelatedModelAttributeValue($model, $attribute); - - continue; - } - - if (Str::contains($attribute, '->')) { - Arr::set( - $changes, - str_replace('->', '.', $attribute), - static::getModelAttributeJsonValue($model, $attribute) - ); - - continue; - } - - $changes[$attribute] = $model->getAttribute($attribute); - - if (is_null($changes[$attribute])) { - continue; - } - - if ($model->isDateAttribute($attribute)) { - $changes[$attribute] = $model->serializeDate( - $model->asDateTime($changes[$attribute]) - ); - } - - if ($model->hasCast($attribute)) { - $cast = $model->getCasts()[$attribute]; - - if ($model->isCustomDateTimeCast($cast)) { - $changes[$attribute] = $model->asDateTime($changes[$attribute])->format(explode(':', $cast, 2)[1]); - } - } - } - - return $changes; - } - - protected static function getRelatedModelAttributeValue(Model $model, string $attribute): array - { - $relatedModelNames = explode('.', $attribute); - $relatedAttribute = array_pop($relatedModelNames); - - $attributeName = []; - $relatedModel = $model; - - do { - $attributeName[] = $relatedModelName = static::getRelatedModelRelationName($relatedModel, array_shift($relatedModelNames)); - - $relatedModel = $relatedModel->$relatedModelName ?? $relatedModel->$relatedModelName(); - } while (! empty($relatedModelNames)); - - $attributeName[] = $relatedAttribute; - - return [implode('.', $attributeName) => $relatedModel->$relatedAttribute ?? null]; - } - - protected static function getRelatedModelRelationName(Model $model, string $relation): string - { - return Arr::first([ - $relation, - Str::snake($relation), - Str::camel($relation), - ], function (string $method) use ($model): bool { - return method_exists($model, $method); - }, $relation); - } - - protected static function getModelAttributeJsonValue(Model $model, string $attribute) - { - $path = explode('->', $attribute); - $modelAttribute = array_shift($path); - $modelAttribute = collect($model->getAttribute($modelAttribute)); - - return data_get($modelAttribute, implode('.', $path)); - } -} diff --git a/src/Traits/LogsActivity.php b/src/Traits/LogsActivity.php index d0c5a115..f2d98534 100644 --- a/src/Traits/LogsActivity.php +++ b/src/Traits/LogsActivity.php @@ -5,47 +5,99 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Pipeline\Pipeline; use Illuminate\Support\Arr; use Illuminate\Support\Collection; +use Illuminate\Support\Str; use Spatie\Activitylog\ActivityLogger; use Spatie\Activitylog\ActivitylogServiceProvider; use Spatie\Activitylog\ActivityLogStatus; +use Spatie\Activitylog\EventLogBag; +use Spatie\Activitylog\LogOptions; trait LogsActivity { - use DetectsChanges; - - protected $enableLoggingModelsEvents = true; - - protected static function bootLogsActivity() + /** + * User defined pipes that will interact with the changes. + **/ + public static array $changesPipes = []; + + /** + * Hold original attributes to compare it against changes. + **/ + protected array $oldAttributes = []; + + /** + * Configuration object on the model. + **/ + protected LogOptions $activitylogOptions; + + /** + * Indicates if logging is currently active. + **/ + public bool $enableLoggingModelsEvents = true; + + /** + * Contract function to define desired settings on the model. + **/ + abstract public function getActivitylogOptions(): LogOptions; + + /** + * Boot instantly after model is booted. + **/ + protected static function bootLogsActivity(): void { + // Hook into eloquent events that only specified in $eventToBeRecorded array, + // checking for "updated" event hook explicitly to temporary hold original + // attributes on the model as we'll need them later to compare against. + static::eventsToBeRecorded()->each(function ($eventName) { - return static::$eventName(function (Model $model) use ($eventName) { - /** @var Model|LogsActivity $model */ + if ($eventName === "updated") { + static::updating(function (Model $model) { + $oldValues = (new static)->setRawAttributes($model->getRawOriginal()); + $model->oldAttributes = static::logChanges($oldValues); + }); + } + + static::$eventName(function (Model $model) use ($eventName) { + $model->activitylogOptions = $model->getActivitylogOptions(); + if (! $model->shouldLogEvent($eventName)) { return; } - $attrs = $model->attributeValuesToBeLogged($eventName); + $changes = $model->attributeValuesToBeLogged($eventName); + - if ($model->isLogEmpty($attrs) && ! $model->shouldSubmitEmptyLogs()) { - return; - } - $description = $model->getDescriptionForEvent($eventName, $attrs); + $description = $model->getDescriptionForEvent($eventName); - $logName = $model->getLogNameToUse($eventName); + $logName = $model->getLogNameToUse(); + // Submiting empty description will cause place holder replacer to fail. if ($description == '') { return; } + + if ($model->isLogEmpty($changes) && ! $model->activitylogOptions->submitEmptyLogs) { + return; + } + + // User can define a custom pipelines to mutate, add or remove from changes + // each pipe receives the event carrier bag with changes and the model. + $event = app(Pipeline::class) + ->send(new EventLogBag($eventName, $model, $changes, $model->activitylogOptions)) + ->through(static::$changesPipes) + ->thenReturn(); + + $logger = app(ActivityLogger::class) ->useLog($logName) ->event($eventName) ->performedOn($model) - ->withProperties($attrs); + ->withProperties($event->changes); if (method_exists($model, 'tapActivity')) { $logger->tap([$model, 'tapActivity'], $eventName); @@ -56,24 +108,27 @@ protected static function bootLogsActivity() }); } - public function shouldSubmitEmptyLogs(): bool + /** + * Undocumented function + **/ + public static function addLogChange($pipe) { - return ! isset(static::$submitEmptyLogs) ? true : static::$submitEmptyLogs; + static::$changesPipes[] = $pipe; } - public function isLogEmpty($attrs): bool + public function isLogEmpty(array $changes): bool { - return empty($attrs['attributes'] ?? []) && empty($attrs['old'] ?? []); + return empty($changes['attributes'] ?? []) && empty($changes['old'] ?? []); } - public function disableLogging() + public function disableLogging(): self { $this->enableLoggingModelsEvents = false; return $this; } - public function enableLogging() + public function enableLogging(): self { $this->enableLoggingModelsEvents = true; @@ -85,23 +140,27 @@ public function activities(): MorphMany return $this->morphMany(ActivitylogServiceProvider::determineActivityModel(), 'subject'); } - public function getDescriptionForEvent(string $eventName, array $attributes): string + public function getDescriptionForEvent(string $eventName): string { + if (! empty($this->activitylogOptions->descriptionForEvent)) { + return ($this->activitylogOptions->descriptionForEvent)($eventName); + } + return $eventName; } - public function getLogNameToUse(string $eventName = ''): string + public function getLogNameToUse(): string { - if (isset(static::$logName)) { - return static::$logName; + if (! empty($this->activitylogOptions->logName)) { + return $this->activitylogOptions->logName; } return config('activitylog.default_log_name'); } - /* + /** * Get the event names that should be recorded. - */ + **/ protected static function eventsToBeRecorded(): Collection { if (isset(static::$recordEvents)) { @@ -121,14 +180,6 @@ protected static function eventsToBeRecorded(): Collection return $events; } - public function attributesToBeIgnored(): array - { - if (! isset(static::$ignoreChangedAttributes)) { - return []; - } - - return static::$ignoreChangedAttributes; - } protected function shouldLogEvent(string $eventName): bool { @@ -148,7 +199,216 @@ protected function shouldLogEvent(string $eventName): bool } } - //do not log update event if only ignored attributes are changed - return (bool) count(Arr::except($this->getDirty(), $this->attributesToBeIgnored())); + // Do not log update event if only ignored attributes are changed. + return (bool) count(Arr::except($this->getDirty(), $this->activitylogOptions->dontLogIfAttributesChangedOnly)); + } + + /** + * Determines what attributes needs to be logged based on the configuration. + **/ + public function attributesToBeLogged(): array + { + $this->activitylogOptions = $this->getActivitylogOptions(); + + $attributes = []; + + // Check if fillable attributes will be logged then merge it to the local attributes array. + if ($this->activitylogOptions->logFillable) { + $attributes = array_merge($attributes, $this->getFillable()); + } + + // Determine if unguarded attributes will be logged. + if ($this->shouldLogUnguarded()) { + + // Get only attribute names, not intrested in the values here then guarded + // attributes. get only keys than not present in guarded array, because + // we are logging the unguarded attributes and we cant have both! + + $attributes = array_merge($attributes, array_diff(array_keys($this->getAttributes()), $this->getGuarded())); + } + + if (! empty($this->activitylogOptions->logAttributes)) { + + // Filter * from the logAttributes because will deal with it separately + $attributes = array_merge($attributes, array_diff($this->activitylogOptions->logAttributes, ['*'])); + + // If there's * get all attributes then merge it, dont respect $guarded or $fillable. + if (in_array('*', $this->activitylogOptions->logAttributes)) { + $attributes = array_merge($attributes, array_keys($this->getAttributes())); + } + } + + if ($this->activitylogOptions->logExceptAttributes) { + + // Filter out the attributes defined in ignoredAttributes out of the local array + $attributes = array_diff($attributes, $this->activitylogOptions->logExceptAttributes); + } + + return $attributes; + } + + public function shouldLogUnguarded(): bool + { + if (! $this->activitylogOptions->logUnguarded) { + return false; + } + + // This case means all of the attributes are guarded + // so we'll not have any unguarded anyway. + if (in_array('*', $this->getGuarded())) { + return false; + } + + return true; + } + + /** + * Determines values that will be logged based on the difference. + **/ + public function attributeValuesToBeLogged(string $processingEvent): array + { + // no loggable attributes, no values to be logged! + if (! count($this->attributesToBeLogged())) { + return []; + } + + $properties['attributes'] = static::logChanges( + + // if the current event is retrieved, get the model itself + // else get the fresh default properties from database + // as wouldn't be part of the saved model instance. + $processingEvent == 'retrieved' + ? $this + : ( + $this->exists + ? $this->fresh() ?? $this + : $this + ) + ); + + if (static::eventsToBeRecorded()->contains('updated') && $processingEvent == 'updated') { + + // Fill the attributes with null values. + $nullProperties = array_fill_keys(array_keys($properties['attributes']), null); + + // Populate the old key with keys from database and from old attributes. + $properties['old'] = array_merge($nullProperties, $this->oldAttributes); + + // Fail safe. + $this->oldAttributes = []; + } + + if ($this->activitylogOptions->logOnlyDirty && isset($properties['old'])) { + + // Get difference between the old and new attributes. + $properties['attributes'] = array_udiff_assoc( + $properties['attributes'], + $properties['old'], + function ($new, $old) { + // Strict check for php's weird behaviors + if ($old === null || $new === null) { + return $new === $old ? 0 : 1; + } + + return $new <=> $old; + } + ); + + $properties['old'] = collect($properties['old']) + ->only(array_keys($properties['attributes'])) + ->all(); + } + + if (static::eventsToBeRecorded()->contains('deleted') && $processingEvent == 'deleted') { + $properties['old'] = $properties['attributes']; + unset($properties['attributes']); + } + + return $properties; + } + + public static function logChanges(Model $model): array + { + $changes = []; + $attributes = $model->attributesToBeLogged(); + + foreach ($attributes as $attribute) { + if (Str::contains($attribute, '.')) { + $changes += self::getRelatedModelAttributeValue($model, $attribute); + + continue; + } + + if (Str::contains($attribute, '->')) { + Arr::set( + $changes, + str_replace('->', '.', $attribute), + static::getModelAttributeJsonValue($model, $attribute) + ); + + continue; + } + + $changes[$attribute] = $model->getAttribute($attribute); + + if (is_null($changes[$attribute])) { + continue; + } + + if ($model->isDateAttribute($attribute)) { + $changes[$attribute] = $model->serializeDate( + $model->asDateTime($changes[$attribute]) + ); + } + + if ($model->hasCast($attribute)) { + $cast = $model->getCasts()[$attribute]; + + if ($model->isCustomDateTimeCast($cast)) { + $changes[$attribute] = $model->asDateTime($changes[$attribute])->format(explode(':', $cast, 2)[1]); + } + } + } + + return $changes; + } + + protected static function getRelatedModelAttributeValue(Model $model, string $attribute): array + { + $relatedModelNames = explode('.', $attribute); + $relatedAttribute = array_pop($relatedModelNames); + + $attributeName = []; + $relatedModel = $model; + + do { + $attributeName[] = $relatedModelName = static::getRelatedModelRelationName($relatedModel, array_shift($relatedModelNames)); + + $relatedModel = $relatedModel->$relatedModelName ?? $relatedModel->$relatedModelName(); + } while (! empty($relatedModelNames)); + + $attributeName[] = $relatedAttribute; + + return [implode('.', $attributeName) => $relatedModel->$relatedAttribute ?? null]; + } + + protected static function getRelatedModelRelationName(Model $model, string $relation): string + { + return Arr::first([ + $relation, + Str::snake($relation), + Str::camel($relation), + ], function (string $method) use ($model): bool { + return method_exists($model, $method); + }, $relation); + } + + protected static function getModelAttributeJsonValue(Model $model, string $attribute): mixed + { + $path = explode('->', $attribute); + $modelAttribute = array_shift($path); + $modelAttribute = collect($model->getAttribute($modelAttribute)); + + return data_get($modelAttribute, implode('.', $path)); } } diff --git a/tests/ActivityLoggerTest.php b/tests/ActivityLoggerTest.php index 4d96cf8d..4bc8d817 100644 --- a/tests/ActivityLoggerTest.php +++ b/tests/ActivityLoggerTest.php @@ -7,6 +7,7 @@ use Exception; use Illuminate\Support\Collection; use Spatie\Activitylog\Exceptions\CouldNotLogActivity; +use Spatie\Activitylog\Facades\CauserResolver; use Spatie\Activitylog\Models\Activity; use Spatie\Activitylog\Test\Models\Article; use Spatie\Activitylog\Test\Models\User; @@ -101,6 +102,42 @@ public function it_can_log_an_activity_with_a_causer() $this->assertInstanceOf(User::class, $firstActivity->causer); } + /** @test */ + public function it_can_log_an_activity_with_a_causer_other_than_user_model() + { + $article = Article::first(); + + activity() + ->causedBy($article) + ->log($this->activityDescription); + + $firstActivity = Activity::first(); + + $this->assertEquals($article->id, $firstActivity->causer->id); + $this->assertInstanceOf(Article::class, $firstActivity->causer); + } + + + /** @test */ + public function it_can_log_an_activity_with_a_causer_that_has_been_set_from_other_context() + { + $causer = Article::first(); + CauserResolver::setCauser($causer); + + + $article = Article::first(); + + + activity() + ->log($this->activityDescription); + + $firstActivity = Activity::first(); + + $this->assertEquals($article->id, $firstActivity->causer->id); + $this->assertInstanceOf(Article::class, $firstActivity->causer); + } + + /** @test */ public function it_can_log_an_activity_with_a_causer_when_there_is_no_web_guard() { diff --git a/tests/CauserResolverTest.php b/tests/CauserResolverTest.php new file mode 100644 index 00000000..a50abace --- /dev/null +++ b/tests/CauserResolverTest.php @@ -0,0 +1,66 @@ +assertInstanceOf(User::class, $causer); + $this->assertEquals($user->id, $causer->id); + } + + + /** @test */ + public function it_will_throw_an_exception_if_it_cannot_resolve_user_by_id() + { + $this->expectException(CouldNotLogActivity::class); + + CauserResolver::resolve(9999); + } + + + + + /** @test */ + public function it_can_resloved_user_from_passed_id() + { + $causer = CauserResolver::resolve(1); + + $this->assertInstanceOf(User::class, $causer); + $this->assertEquals(1, $causer->id); + } + + + /** @test */ + public function it_will_resolve_the_provided_override_callback() + { + CauserResolver::resolveUsing(fn () => Article::first()); + + $causer = CauserResolver::resolve(); + + $this->assertInstanceOf(Article::class, $causer); + $this->assertEquals(1, $causer->id); + } + + + /** @test */ + public function it_will_resolve_any_model() + { + $causer = CauserResolver::resolve($article = Article::first()); + + $this->assertInstanceOf(Article::class, $causer); + $this->assertEquals($article->id, $causer->id); + } +} diff --git a/tests/DetectsChangesTest.php b/tests/DetectsChangesTest.php index c1b7cc2b..66fb5282 100644 --- a/tests/DetectsChangesTest.php +++ b/tests/DetectsChangesTest.php @@ -3,8 +3,13 @@ namespace Spatie\Activitylog\Test; use Carbon\Carbon; +use Closure; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Arr; +use Spatie\Activitylog\Contracts\LoggablePipe; +use Spatie\Activitylog\EventLogBag; +use Spatie\Activitylog\LogBatch; +use Spatie\Activitylog\LogOptions; use Spatie\Activitylog\Models\Activity; use Spatie\Activitylog\Test\Models\Article; use Spatie\Activitylog\Test\Models\User; @@ -20,9 +25,13 @@ public function setUp(): void parent::setUp(); $this->article = new class() extends Article { - public static $logAttributes = ['name', 'text']; - use LogsActivity; + + public function getActivitylogOptions() : LogOptions + { + return LogOptions::defaults() + ->logOnly(['name', 'text']); + } }; $this->assertCount(0, Activity::all()); @@ -43,13 +52,89 @@ public function it_can_store_the_values_when_creating_a_model() $this->assertEquals($expectedChanges, $this->getLastActivity()->changes()->toArray()); } + + /** @test */ - public function it_can_store_the_relation_values_when_creating_a_model() + public function it_deep_diff_check_json_field() { $articleClass = new class() extends Article { - public static $logAttributes = ['name', 'text', 'user.name']; + use LogsActivity; + + protected $casts = [ + 'json' => 'collection', + ]; + + public function getActivitylogOptions() : LogOptions + { + return LogOptions::defaults() + ->dontSubmitEmptyLogs() + ->logOnlyDirty() + ->logOnly(['json->phone', 'json->details', 'json->address']); + } + }; + + $articleClass::addLogChange(new class() implements LoggablePipe { + public function handle(EventLogBag $event, Closure $next): EventLogBag + { + if ($event->event === "updated") { + $event->changes['attributes']['json'] = array_udiff_assoc( + $event->changes['attributes']['json'], + $event->changes['old']['json'], + function ($new, $old) { + if ($old === null || $new === null) { + return 0; + } + + return $new <=> $old; + } + ); + + $event->changes['old']['json'] = collect($event->changes['old']['json']) + ->only(array_keys($event->changes['attributes']['json'])) + ->all(); + } + + + return $next($event); + } + }); + + $article = $articleClass::create([ + 'name' => 'Hamburg', + 'json' => ['details' => '', 'phone' => '1231231234', 'address' => 'new address'], + ]); + + + $article->update(['json' => ['details' => 'new details']]); + + $expectedChanges = [ + 'attributes' => [ + 'json' => [ + 'details' => 'new details', + ], + ], + 'old' => [ + 'json' => [ + 'details' => '', + ], + ], + ]; + + $this->assertEquals($expectedChanges, $this->getLastActivity()->changes()->toArray()); + } + + /** @test */ + public function it_can_store_the_relation_values_when_creating_a_model() + { + $articleClass = new class() extends Article { use LogsActivity; + + public function getActivitylogOptions() : LogOptions + { + return LogOptions::defaults() + ->logOnly(['name', 'text', 'user.name']); + } }; $user = User::create([ @@ -82,13 +167,169 @@ public function it_can_store_the_relation_values_when_creating_a_model() $this->assertEquals($expectedChanges, $this->getLastActivity()->changes()->toArray()); } + /** @test */ - public function it_can_store_empty_relation_when_creating_a_model() + public function it_retruns_same_uuid_for_all_log_changes_under_one_batch() + { + $articleClass = new class() extends Article { + use LogsActivity; + use SoftDeletes; + + public function getActivitylogOptions() : LogOptions + { + return LogOptions::defaults() + ->logOnly(['name', 'text']); + } + }; + + app(LogBatch::class)->startBatch(); + + $user = User::create([ + 'name' => 'user name', + ]); + + $article = $articleClass::create([ + 'name' => 'original name', + 'text' => 'original text', + 'user_id' => $user->id, + ]); + + $article->name = 'updated name'; + $article->text = 'updated text'; + $article->save(); + + $article->delete(); + $article->forceDelete(); + + $batchUuid = app(LogBatch::class)->getUuid(); + + app(LogBatch::class)->endBatch(); + + + $this->assertTrue(Activity::pluck('batch_uuid')->every(fn ($uuid) => $uuid === $batchUuid)); + } + + + /** @test */ + public function it_assigns_new_uuid_for_multiple_change_logs_in_different_batches() + { + $articleClass = new class() extends Article { + use LogsActivity; + use SoftDeletes; + + public function getActivitylogOptions() : LogOptions + { + return LogOptions::defaults() + ->logOnly(['name', 'text']); + } + }; + + app(LogBatch::class)->startBatch(); + + $uuidForCreatedEvent = app(LogBatch::class)->getUuid(); + $user = User::create([ + 'name' => 'user name', + ]); + + $article = $articleClass::create([ + 'name' => 'original name', + 'text' => 'original text', + 'user_id' => $user->id, + ]); + + app(LogBatch::class)->endBatch(); + + $this->assertTrue(Activity::pluck('batch_uuid')->every(fn ($uuid) => $uuid === $uuidForCreatedEvent)); + + + app(LogBatch::class)->startBatch(); + + $article->name = 'updated name'; + $article->text = 'updated text'; + $article->save(); + $uuidForUpdatedEvents = app(LogBatch::class)->getUuid(); + + app(LogBatch::class)->endBatch(); + + $this->assertCount(1, Activity::where('description', 'updated')->get()); + + $this->assertEquals($uuidForUpdatedEvents, Activity::where('description', 'updated')->first()->batch_uuid); + + app(LogBatch::class)->startBatch(); + $article->delete(); + $article->forceDelete(); + + $uuidForDeletedEvents = app(LogBatch::class)->getUuid(); + + app(LogBatch::class)->endBatch(); + + + $this->assertCount(2, Activity::where('batch_uuid', $uuidForDeletedEvents)->get()); + + $this->assertNotSame($uuidForCreatedEvent, $uuidForDeletedEvents); + } + + /** @test */ + public function it_can_removes_key_event_if_it_was_loggable() { $articleClass = new class() extends Article { - public static $logAttributes = ['name', 'text', 'user.name']; + use LogsActivity; + + public function getActivitylogOptions() : LogOptions + { + return LogOptions::defaults() + ->logOnly(['name', 'text', 'user.name']); + } + }; + $user = User::create([ + 'name' => 'user name', + ]); + + $articleClass::addLogChange(new class() implements LoggablePipe { + public function handle(EventLogBag $event, Closure $next): EventLogBag + { + Arr::forget($event->changes, ['attributes.name', 'old.name']); + + return $next($event); + } + }); + + $article = $articleClass::create([ + 'name' => 'original name', + 'text' => 'original text', + 'user_id' => $user->id, + ]); + + $article->name = 'updated name'; + $article->text = 'updated text'; + $article->save(); + + $expectedChanges = [ + 'attributes' => [ + 'text' => 'updated text', + 'user.name' => 'user name', + ], + 'old' => [ + 'text' => 'original text', + 'user.name' => 'user name', + ], + ]; + + $this->assertEquals($expectedChanges, $this->getLastActivity()->changes()->toArray()); + } + + /** @test */ + public function it_can_store_empty_relation_when_creating_a_model() + { + $articleClass = new class() extends Article { use LogsActivity; + + public function getActivitylogOptions() : LogOptions + { + return LogOptions::defaults() + ->logOnly(['name', 'text', 'user.name']); + } }; $user = User::create([ @@ -196,9 +437,13 @@ public function it_can_store_dirty_changes_for_swapping_values() public function it_can_store_the_changes_when_updating_a_related_model() { $articleClass = new class() extends Article { - public static $logAttributes = ['name', 'text', 'user.name']; - use LogsActivity; + + public function getActivitylogOptions() : LogOptions + { + return LogOptions::defaults() + ->logOnly(['name', 'text', 'user.name']); + } }; $user = User::create([ @@ -237,10 +482,14 @@ public function it_can_store_the_changes_when_updating_a_related_model() public function it_can_store_the_changes_when_updating_a_snake_case_related_model() { $articleClass = new class() extends Article { - public static $logAttributes = ['name', 'text', 'snakeUser.name']; - use LogsActivity; + public function getActivitylogOptions() : LogOptions + { + return LogOptions::defaults() + ->logOnly(['name', 'text', 'snakeUser.name']); + } + public function snake_user() { return $this->belongsTo(User::class, 'user_id'); @@ -283,10 +532,14 @@ public function snake_user() public function it_can_store_the_changes_when_updating_a_camel_case_related_model() { $articleClass = new class() extends Article { - public static $logAttributes = ['name', 'text', 'camel_user.name']; - use LogsActivity; + public function getActivitylogOptions() : LogOptions + { + return LogOptions::defaults() + ->logOnly(['name', 'text', 'camel_user.name']); + } + public function camelUser() { return $this->belongsTo(User::class, 'user_id'); @@ -329,10 +582,14 @@ public function camelUser() public function it_can_store_the_changes_when_updating_a_custom_case_related_model() { $articleClass = new class() extends Article { - public static $logAttributes = ['name', 'text', 'Custom_Case_User.name']; - use LogsActivity; + public function getActivitylogOptions() : LogOptions + { + return LogOptions::defaults() + ->logOnly(['name', 'text', 'Custom_Case_User.name']); + } + public function Custom_Case_User() { return $this->belongsTo(User::class, 'user_id'); @@ -375,11 +632,14 @@ public function Custom_Case_User() public function it_can_store_the_dirty_changes_when_updating_a_related_model() { $articleClass = new class() extends Article { - public static $logAttributes = ['name', 'text', 'user.name']; - - public static $logOnlyDirty = true; - use LogsActivity; + + public function getActivitylogOptions() : LogOptions + { + return LogOptions::defaults() + ->logOnly(['name', 'text', 'user.name']) + ->logOnlyDirty(); + } }; $user = User::create([ @@ -414,9 +674,14 @@ public function it_can_store_the_dirty_changes_when_updating_a_related_model() public function it_can_store_the_changes_when_saving_including_multi_level_related_model() { $articleClass = new class() extends Article { - public static $logAttributes = ['name', 'text', 'user.latest_article.name']; - use LogsActivity; + + public function getActivitylogOptions() : LogOptions + { + return LogOptions::defaults() + ->logOnly(['name', 'text', 'user.latest_article.name']) + ->logOnlyDirty(); + } }; $user = User::create([ @@ -450,9 +715,13 @@ public function it_can_store_the_changes_when_saving_including_multi_level_relat public function it_will_store_no_changes_when_not_logging_attributes() { $articleClass = new class() extends Article { - public static $logAttributes = []; - use LogsActivity; + + public function getActivitylogOptions() : LogOptions + { + return LogOptions::defaults() + ->logOnly([]); + } }; $article = new $articleClass(); @@ -486,9 +755,13 @@ public function it_will_store_the_values_when_deleting_the_model() public function it_will_store_the_values_when_deleting_the_model_with_softdeletes() { $articleClass = new class() extends Article { - public static $logAttributes = ['name', 'text']; - use LogsActivity, SoftDeletes; + + public function getActivitylogOptions() : LogOptions + { + return LogOptions::defaults() + ->logOnly(['name', 'text']); + } }; $article = new $articleClass(); @@ -527,11 +800,16 @@ public function it_will_store_the_values_when_deleting_the_model_with_softdelete public function it_can_store_the_changes_of_collection_casted_properties() { $articleClass = new class() extends Article { - public static $logAttributes = ['json']; - public static $logOnlyDirty = true; protected $casts = ['json' => 'collection']; use LogsActivity; + + public function getActivitylogOptions() : LogOptions + { + return LogOptions::defaults() + ->logOnly(['json']) + ->logOnlyDirty(); + } }; $article = $articleClass::create([ @@ -560,11 +838,16 @@ public function it_can_store_the_changes_of_collection_casted_properties() public function it_can_store_the_changes_of_array_casted_properties() { $articleClass = new class() extends Article { - public static $logAttributes = ['json']; - public static $logOnlyDirty = true; protected $casts = ['json' => 'array']; use LogsActivity; + + public function getActivitylogOptions() : LogOptions + { + return LogOptions::defaults() + ->logOnly(['json']) + ->logOnlyDirty(); + } }; $article = $articleClass::create([ @@ -593,11 +876,16 @@ public function it_can_store_the_changes_of_array_casted_properties() public function it_can_store_the_changes_of_json_casted_properties() { $articleClass = new class() extends Article { - public static $logAttributes = ['json']; - public static $logOnlyDirty = true; protected $casts = ['json' => 'json']; use LogsActivity; + + public function getActivitylogOptions() : LogOptions + { + return LogOptions::defaults() + ->logOnly(['json']) + ->logOnlyDirty(); + } }; $article = $articleClass::create([ @@ -626,10 +914,15 @@ public function it_can_store_the_changes_of_json_casted_properties() public function it_can_use_nothing_as_loggable_attributes() { $articleClass = new class() extends Article { + use LogsActivity; + protected $fillable = ['name', 'text']; - protected static $logFillable = false; - use LogsActivity; + public function getActivitylogOptions() : LogOptions + { + return LogOptions::defaults() + ->dontLogFillable(); + } }; $article = new $articleClass(); @@ -646,11 +939,16 @@ public function it_can_use_nothing_as_loggable_attributes() public function it_can_use_text_as_loggable_attributes() { $articleClass = new class() extends Article { + use LogsActivity; + protected $fillable = ['name', 'text']; - protected static $logAttributes = ['text']; - protected static $logFillable = false; - use LogsActivity; + public function getActivitylogOptions() : LogOptions + { + return LogOptions::defaults() + ->logOnly(['text']) + ->dontLogFillable(); + } }; $article = new $articleClass(); @@ -671,10 +969,15 @@ public function it_can_use_text_as_loggable_attributes() public function it_can_use_fillable_as_loggable_attributes() { $articleClass = new class() extends Article { + use LogsActivity; + protected $fillable = ['name', 'text']; - protected static $logFillable = true; - use LogsActivity; + public function getActivitylogOptions() : LogOptions + { + return LogOptions::defaults() + ->logFillable(); + } }; $article = new $articleClass(); @@ -695,11 +998,16 @@ public function it_can_use_fillable_as_loggable_attributes() public function it_can_use_both_fillable_and_log_attributes() { $articleClass = new class() extends Article { + use LogsActivity; + protected $fillable = ['name']; - protected static $logAttributes = ['text']; - protected static $logFillable = true; - use LogsActivity; + public function getActivitylogOptions() : LogOptions + { + return LogOptions::defaults() + ->logOnly(['text']) + ->logFillable(); + } }; $article = new $articleClass(); @@ -721,9 +1029,14 @@ public function it_can_use_both_fillable_and_log_attributes() public function it_can_use_wildcard_for_loggable_attributes() { $articleClass = new class() extends Article { - public static $logAttributes = ['*']; - use LogsActivity; + + + public function getActivitylogOptions() : LogOptions + { + return LogOptions::defaults() + ->logAll(); + } }; $article = new $articleClass(); @@ -753,9 +1066,13 @@ public function it_can_use_wildcard_for_loggable_attributes() public function it_can_use_wildcard_with_relation() { $articleClass = new class() extends Article { - public static $logAttributes = ['*', 'user.name']; - use LogsActivity; + + public function getActivitylogOptions() : LogOptions + { + return LogOptions::defaults() + ->logOnly(['*', 'user.name']); + } }; $user = User::create([ @@ -792,10 +1109,14 @@ public function it_can_use_wildcard_with_relation() public function it_can_use_wildcard_when_updating_model() { $articleClass = new class() extends Article { - public static $logAttributes = ['*']; - public static $logOnlyDirty = true; - use LogsActivity; + + public function getActivitylogOptions() : LogOptions + { + return LogOptions::defaults() + ->logAll() + ->logOnlyDirty(); + } }; $user = User::create([ @@ -831,14 +1152,18 @@ public function it_can_use_wildcard_when_updating_model() public function it_can_store_the_changes_when_a_boolean_field_is_changed_from_false_to_null() { $articleClass = new class() extends Article { - public static $logAttributes = ['*']; - public static $logOnlyDirty = true; + use LogsActivity; protected $casts = [ 'text' => 'boolean', ]; - use LogsActivity; + public function getActivitylogOptions() : LogOptions + { + return LogOptions::defaults() + ->logAll() + ->logOnlyDirty(); + } }; $user = User::create([ @@ -875,10 +1200,14 @@ public function it_can_store_the_changes_when_a_boolean_field_is_changed_from_fa public function it_can_use_ignored_attributes_while_updating() { $articleClass = new class() extends Article { - public static $logAttributes = ['*']; - public static $logAttributesToIgnore = ['name', 'updated_at']; - use LogsActivity; + + public function getActivitylogOptions() : LogOptions + { + return LogOptions::defaults() + ->logAll() + ->logExcept(['name', 'updated_at']); + } }; $article = new $articleClass(); @@ -906,11 +1235,15 @@ public function it_can_use_ignored_attributes_while_updating() public function it_can_use_unguarded_as_loggable_attributes() { $articleClass = new class() extends Article { - protected $guarded = ['text', 'json']; - protected static $logAttributesToIgnore = ['id', 'created_at', 'updated_at', 'deleted_at']; - protected static $logUnguarded = true; - use LogsActivity; + + protected $guarded = ['text', 'json']; + public function getActivitylogOptions() : LogOptions + { + return LogOptions::defaults() + ->logUnguarded() + ->logExcept(['id', 'created_at', 'updated_at', 'deleted_at']); + } }; $article = new $articleClass(); @@ -933,10 +1266,15 @@ public function it_can_use_unguarded_as_loggable_attributes() public function it_will_store_no_changes_when_wildcard_guard_and_log_unguarded_attributes() { $articleClass = new class() extends Article { + use LogsActivity; + protected $guarded = ['*']; - protected static $logUnguarded = true; - use LogsActivity; + public function getActivitylogOptions() : LogOptions + { + return LogOptions::defaults() + ->logUnguarded(); + } }; $article = new $articleClass(); @@ -951,11 +1289,16 @@ public function it_will_store_no_changes_when_wildcard_guard_and_log_unguarded_a public function it_can_use_hidden_as_loggable_attributes() { $articleClass = new class() extends Article { + use LogsActivity; + protected $hidden = ['text']; protected $fillable = ['name', 'text']; - protected static $logAttributes = ['name', 'text']; - use LogsActivity; + public function getActivitylogOptions() : LogOptions + { + return LogOptions::defaults() + ->logOnly(['name', 'text']); + } }; $article = new $articleClass(); @@ -977,10 +1320,15 @@ public function it_can_use_hidden_as_loggable_attributes() public function it_can_use_overloaded_as_loggable_attributes() { $articleClass = new class() extends Article { + use LogsActivity; + protected $fillable = ['name', 'text']; - protected static $logAttributes = ['name', 'text', 'description']; - use LogsActivity; + public function getActivitylogOptions() : LogOptions + { + return LogOptions::defaults() + ->logOnly(['name', 'text', 'description']); + } public function setDescriptionAttribute($value) { @@ -1014,10 +1362,15 @@ public function getDescriptionAttribute() public function it_can_use_mutated_as_loggable_attributes() { $userClass = new class() extends User { + use LogsActivity; + protected $fillable = ['name', 'text']; - protected static $logAttributes = ['*']; - use LogsActivity; + public function getActivitylogOptions() : LogOptions + { + return LogOptions::defaults() + ->logAll(); + } public function setNameAttribute($value) { @@ -1073,10 +1426,15 @@ public function setNameAttribute($value) public function it_can_use_accessor_as_loggable_attributes() { $userClass = new class() extends User { + use LogsActivity; + protected $fillable = ['name', 'text']; - protected static $logAttributes = ['*']; - use LogsActivity; + public function getActivitylogOptions() : LogOptions + { + return LogOptions::defaults() + ->logAll(); + } public function getNameAttribute($value) { @@ -1132,11 +1490,17 @@ public function getNameAttribute($value) public function it_can_use_encrypted_as_loggable_attributes() { $userClass = new class() extends User { + use LogsActivity; + protected $fillable = ['name', 'text']; protected $encryptable = ['name', 'text']; - protected static $logAttributes = ['name', 'text']; - use LogsActivity; + + public function getActivitylogOptions() : LogOptions + { + return LogOptions::defaults() + ->logOnly(['name', 'text']); + } public function getAttributeValue($key) { @@ -1195,13 +1559,18 @@ public function setAttribute($key, $value) public function it_can_use_casted_as_loggable_attribute() { $articleClass = new class() extends Article { - protected static $logAttributes = ['name', 'text', 'price']; - public static $logOnlyDirty = true; + use LogsActivity; + protected $casts = [ 'price' => 'float', ]; - use LogsActivity; + public function getActivitylogOptions() : LogOptions + { + return LogOptions::defaults() + ->logOnly(['name', 'text', 'price']) + ->logOnlyDirty(); + } }; $article = new $articleClass(); @@ -1243,15 +1612,21 @@ public function it_can_use_casted_as_loggable_attribute() public function it_can_use_nullable_date_as_loggable_attributes() { $userClass = new class() extends User { + use LogsActivity, SoftDeletes; + protected $fillable = ['name', 'text']; - protected static $logAttributes = ['*']; + protected $dates = [ 'created_at', 'updated_at', 'deleted_at', ]; - use LogsActivity, SoftDeletes; + public function getActivitylogOptions() : LogOptions + { + return LogOptions::defaults() + ->logAll(); + } }; Carbon::setTestNow(Carbon::create(2017, 1, 1, 12, 0, 0)); @@ -1278,13 +1653,18 @@ public function it_can_use_nullable_date_as_loggable_attributes() public function it_can_use_custom_date_cast_as_loggable_attributes() { $userClass = new class() extends User { + use LogsActivity; + protected $fillable = ['name', 'text']; - protected static $logAttributes = ['*']; protected $casts = [ 'created_at' => 'date:d.m.Y', ]; - use LogsActivity; + public function getActivitylogOptions() : LogOptions + { + return LogOptions::defaults() + ->logAll(); + } }; Carbon::setTestNow(Carbon::create(2017, 1, 1, 12, 0, 0)); @@ -1311,13 +1691,18 @@ public function it_can_use_custom_date_cast_as_loggable_attributes() public function it_can_store_the_changes_of_json_attributes() { $articleClass = new class() extends Article { - protected static $logAttributes = ['name', 'json->data']; - public static $logOnlyDirty = true; + use LogsActivity; + protected $casts = [ 'json' => 'collection', ]; - use LogsActivity; + public function getActivitylogOptions() : LogOptions + { + return LogOptions::defaults() + ->logOnly(['name', 'json->data']) + ->logOnlyDirty(); + } }; $article = new $articleClass(); @@ -1343,13 +1728,18 @@ public function it_can_store_the_changes_of_json_attributes() public function it_will_not_store_changes_to_untracked_json() { $articleClass = new class() extends Article { - protected static $logAttributes = ['name', 'json->data']; - public static $logOnlyDirty = true; + use LogsActivity; + protected $casts = [ 'json' => 'collection', ]; - use LogsActivity; + public function getActivitylogOptions() : LogOptions + { + return LogOptions::defaults() + ->logOnly(['name', 'json->data']) + ->logOnlyDirty(); + } }; $article = new $articleClass(); @@ -1379,13 +1769,18 @@ public function it_will_not_store_changes_to_untracked_json() public function it_will_return_null_for_missing_json_attribute() { $articleClass = new class() extends Article { - protected static $logAttributes = ['name', 'json->data->missing']; - public static $logOnlyDirty = true; + use LogsActivity; + protected $casts = [ 'json' => 'collection', ]; - use LogsActivity; + public function getActivitylogOptions() : LogOptions + { + return LogOptions::defaults() + ->logOnly(['name', 'json->data->missing']) + ->logOnlyDirty(); + } }; $jsonToStore = []; @@ -1426,13 +1821,18 @@ public function it_will_return_null_for_missing_json_attribute() public function it_will_return_an_array_for_sub_key_in_json_attribute() { $articleClass = new class() extends Article { - protected static $logAttributes = ['name', 'json->data']; - public static $logOnlyDirty = true; + use LogsActivity; + protected $casts = [ 'json' => 'collection', ]; - use LogsActivity; + public function getActivitylogOptions() : LogOptions + { + return LogOptions::defaults() + ->logOnly(['name', 'json->data']) + ->logOnlyDirty(); + } }; $jsonToStore = [ @@ -1489,13 +1889,18 @@ public function it_will_return_an_array_for_sub_key_in_json_attribute() public function it_will_access_further_than_level_one_json_attribute() { $articleClass = new class() extends Article { - protected static $logAttributes = ['name', 'json->data->can->go->how->far']; - public static $logOnlyDirty = true; + use LogsActivity; + protected $casts = [ 'json' => 'collection', ]; - use LogsActivity; + public function getActivitylogOptions() : LogOptions + { + return LogOptions::defaults() + ->logOnly(['name', 'json->data->can->go->how->far']) + ->logOnlyDirty(); + } }; $jsonToStore = []; @@ -1557,11 +1962,14 @@ protected function createArticle(): Article protected function createDirtyArticle(): Article { $articleClass = new class() extends Article { - public static $logAttributes = ['name', 'text']; - - public static $logOnlyDirty = true; - use LogsActivity; + + public function getActivitylogOptions() : LogOptions + { + return LogOptions::defaults() + ->logOnly(['name', 'text']) + ->logOnlyDirty(); + } }; $article = new $articleClass(); diff --git a/tests/LogBatchTest.php b/tests/LogBatchTest.php new file mode 100644 index 00000000..eb2b3769 --- /dev/null +++ b/tests/LogBatchTest.php @@ -0,0 +1,124 @@ +assertFalse(LogBatch::isopen()); + + $this->assertIsString($uuid); + } + + /** @test */ + public function it_returns_null_uuid_after_end_batch_properely() + { + LogBatch::startBatch(); + $uuid = LogBatch::getUuid(); + LogBatch::endBatch(); + + + $this->assertFalse(LogBatch::isopen()); + $this->assertNotNull($uuid); + $this->assertNull(LogBatch::getUuid()); + } + + + /** @test */ + public function it_generates_a_new_uuid_after_starting_new_batch_properly() + { + LogBatch::startBatch(); + $firstBatchUuid = LogBatch::getUuid(); + LogBatch::endBatch(); + + LogBatch::startBatch(); + + LogBatch::startBatch(); + $secondBatchUuid = LogBatch::getUuid(); + LogBatch::endBatch(); + + $this->assertTrue(LogBatch::isopen()); + $this->assertNotNull($firstBatchUuid); + $this->assertNotNull($secondBatchUuid); + + $this->assertNotEquals($firstBatchUuid, $secondBatchUuid); + } + + + /** @test */ + public function it_will_not_generate_new_uuid_if_start_already_started_batch() + { + LogBatch::startBatch(); + + $firstUuid = LogBatch::getUuid(); + + LogBatch::startBatch(); + + $secondUuid = LogBatch::getUuid(); + + LogBatch::endBatch(); + + + $this->assertTrue(LogBatch::isopen()); + + $this->assertEquals($firstUuid, $secondUuid); + } + + + /** @test */ + public function it_will_not_generate_uuid_if_end_batch_before_starting() + { + LogBatch::endBatch(); + $uuid = LogBatch::getUuid(); + + LogBatch::startBatch(); + + $this->assertNull($uuid); + } + + /** @test */ + public function it_will_not_return_null_uuid_if_end_batch_that_started_twice() + { + LogBatch::startBatch(); + $firstUuid = LogBatch::getUuid(); + + LogBatch::startBatch(); + + LogBatch::endBatch(); + + $notNullUuid = LogBatch::getUuid(); + + + $this->assertNotNull($firstUuid); + $this->assertNotNull($notNullUuid); + + $this->assertSame($firstUuid, $notNullUuid); + } + + /** @test */ + public function it_will_return_null_uuid_if_end_batch_that_started_twice_properly() + { + LogBatch::startBatch(); + $firstUuid = LogBatch::getUuid(); + + LogBatch::startBatch(); + + LogBatch::endBatch(); + LogBatch::endBatch(); + + $nullUuid = LogBatch::getUuid(); + + $this->assertNotNull($firstUuid); + $this->assertNull($nullUuid); + + $this->assertNotSame($firstUuid, $nullUuid); + } +} diff --git a/tests/LogsActivityTest.php b/tests/LogsActivityTest.php index 4fb4bc85..5b249602 100644 --- a/tests/LogsActivityTest.php +++ b/tests/LogsActivityTest.php @@ -5,6 +5,7 @@ use Carbon\Carbon; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Collection; +use Spatie\Activitylog\LogOptions; use Spatie\Activitylog\Models\Activity; use Spatie\Activitylog\Test\Models\Article; use Spatie\Activitylog\Test\Models\Issue733; @@ -25,11 +26,22 @@ public function setUp(): void $this->article = new class() extends Article { use LogsActivity; use SoftDeletes; + + public function getActivitylogOptions() : LogOptions + { + return LogOptions::defaults(); + } }; $this->user = new class() extends User { use LogsActivity; use SoftDeletes; + + + public function getActivitylogOptions() : LogOptions + { + return LogOptions::defaults(); + } }; $this->assertCount(0, Activity::all()); @@ -53,6 +65,7 @@ public function it_can_skip_logging_model_events_if_asked_to() $article = new $this->article(); $article->disableLogging(); $article->name = 'my name'; + $article->save(); $this->assertCount(0, Activity::all()); @@ -110,7 +123,11 @@ public function it_will_log_the_deletion_of_a_model_without_softdeletes() { $articleClass = new class() extends Article { use LogsActivity; - protected static $logAttributes = ['name']; + + public function getActivitylogOptions() : LogOptions + { + return LogOptions::defaults()->logOnly(['name']); + } }; $article = new $articleClass(); @@ -209,25 +226,6 @@ public function it_can_fetch_soft_deleted_models() $this->assertEquals('changed name', $this->getLastActivity()->subject->name); } - /** @test */ - public function it_can_log_activity_to_log_returned_from_model_method_override() - { - $articleClass = new class() extends Article { - use LogsActivity; - - public function getLogNameToUse() - { - return 'custom_log'; - } - }; - - $article = new $articleClass(); - $article->name = 'my name'; - $article->save(); - - $this->assertEquals($article->id, Activity::inLog('custom_log')->first()->subject->id); - $this->assertCount(1, Activity::inLog('custom_log')->get()); - } /** @test */ public function it_can_log_activity_to_log_named_in_the_model() @@ -235,7 +233,11 @@ public function it_can_log_activity_to_log_named_in_the_model() $articleClass = new class() extends Article { use LogsActivity; - protected static $logName = 'custom_log'; + public function getActivitylogOptions() : LogOptions + { + return LogOptions::defaults() + ->useLogName('custom_log'); + } }; $article = new $articleClass(); @@ -251,7 +253,11 @@ public function it_will_not_log_an_update_of_the_model_if_only_ignored_attribute $articleClass = new class() extends Article { use LogsActivity; - protected static $ignoreChangedAttributes = ['text']; + public function getActivitylogOptions() : LogOptions + { + return LogOptions::defaults() + ->dontLogIfAttributesChangedOnly([ 'text']); + } }; $article = new $articleClass(); @@ -276,9 +282,10 @@ public function it_will_not_fail_if_asked_to_replace_from_empty_attribute() use LogsActivity; use SoftDeletes; - public function getDescriptionForEvent(string $eventName, array $attributes): string + public function getActivitylogOptions() : LogOptions { - return ":causer.name $eventName"; + return LogOptions::defaults() + ->setDescriptionForEvent(fn (string $eventName):string => ":causer.name $eventName"); } }; @@ -319,6 +326,11 @@ public function it_can_log_activity_when_attributes_are_changed_with_tap() $model = new class() extends Article { use LogsActivity; + public function getActivitylogOptions() : LogOptions + { + return LogOptions::defaults(); + } + protected $properties = [ 'property' => [ 'subProperty' => 'value', @@ -352,6 +364,11 @@ public function it_can_log_activity_when_description_is_changed_with_tap() $model = new class() extends Article { use LogsActivity; + public function getActivitylogOptions() : LogOptions + { + return LogOptions::defaults(); + } + public function tapActivity(Activity $activity, string $eventName) { $activity->description = 'my custom description'; @@ -366,12 +383,18 @@ public function tapActivity(Activity $activity, string $eventName) $this->assertEquals('my custom description', $firstActivity->description); } + /** @test */ public function it_can_log_activity_when_event_is_changed_with_tap() { $model = new class() extends Article { use LogsActivity; + public function getActivitylogOptions(): LogOptions + { + return LogOptions::defaults(); + } + public function tapActivity(Activity $activity, string $eventName) { $activity->event = 'my custom event'; @@ -392,9 +415,13 @@ public function it_will_not_submit_log_when_there_is_no_changes() $model = new class() extends Article { use LogsActivity; - protected static $submitEmptyLogs = false; - protected static $logAttributes = ['text']; - protected static $logOnlyDirty = true; + public function getActivitylogOptions() : LogOptions + { + return LogOptions::defaults() + ->logOnly(['text']) + ->dontSubmitEmptyLogs() + ->logOnlyDirty(); + } }; $entity = new $model(['text' => 'test']); @@ -414,12 +441,17 @@ public function it_will_submit_a_log_with_json_changes() $model = new class() extends Article { use LogsActivity; - protected static $submitEmptyLogs = false; - protected static $logAttributes = ['text', 'json->data']; - public static $logOnlyDirty = true; protected $casts = [ 'json' => 'collection', ]; + + public function getActivitylogOptions() : LogOptions + { + return LogOptions::defaults() + ->logOnly(['text', 'json->data']) + ->dontSubmitEmptyLogs() + ->logOnlyDirty(); + } }; $entity = new $model([ diff --git a/tests/Models/Activity.php b/tests/Models/Activity.php index 67301643..279f9077 100644 --- a/tests/Models/Activity.php +++ b/tests/Models/Activity.php @@ -40,7 +40,7 @@ public function causer(): MorphTo return $this->morphTo(); } - public function getExtraProperty(string $propertyName) + public function getExtraProperty(string $propertyName): mixed { return Arr::get($this->properties->toArray(), $propertyName); } diff --git a/tests/Models/AnotherInvalidActivity.php b/tests/Models/AnotherInvalidActivity.php index d77c6d1a..61c02805 100644 --- a/tests/Models/AnotherInvalidActivity.php +++ b/tests/Models/AnotherInvalidActivity.php @@ -45,7 +45,7 @@ public function causer(): MorphTo * * @return mixed */ - public function getExtraProperty(string $propertyName) + public function getExtraProperty(string $propertyName): mixed { return Arr::get($this->properties->toArray(), $propertyName); } diff --git a/tests/Models/Issue733.php b/tests/Models/Issue733.php index e2cab372..e980bb39 100644 --- a/tests/Models/Issue733.php +++ b/tests/Models/Issue733.php @@ -2,6 +2,7 @@ namespace Spatie\Activitylog\Test\Models; +use Spatie\Activitylog\LogOptions; use Spatie\Activitylog\Traits\LogsActivity; class Issue733 extends Article @@ -12,7 +13,10 @@ class Issue733 extends Article 'retrieved', ]; - protected static $submitEmptyLogs = false; - protected static $logAttributes = ['name']; - public static $logOnlyDirty = false; + public function getActivitylogOptions() : LogOptions + { + return LogOptions::defaults() + ->dontSubmitEmptyLogs() + ->logOnly(['name']); + } } diff --git a/tests/TestCase.php b/tests/TestCase.php index 556d94f9..e688ca9d 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,6 +2,7 @@ namespace Spatie\Activitylog\Test; +use AddBatchUuidColumnToActivityLogTable; use AddEventColumnToActivityLogTable; use CreateActivityLogTable; use Illuminate\Database\Schema\Blueprint; @@ -57,9 +58,11 @@ protected function migrateActivityLogTable() { require_once __DIR__.'/../migrations/create_activity_log_table.php.stub'; require_once __DIR__.'/../migrations/add_event_column_to_activity_log_table.php.stub'; + require_once __DIR__.'/../migrations/add_batch_uuid_column_to_activity_log_table.php.stub'; (new CreateActivityLogTable())->up(); (new AddEventColumnToActivityLogTable())->up(); + (new AddBatchUuidColumnToActivityLogTable())->up(); } protected function createTables(...$tableNames)