From c31343de6c01e7f382e7f946d8af94f3deb520d7 Mon Sep 17 00:00:00 2001 From: Luke Towers Date: Wed, 12 Jul 2023 21:13:36 -0600 Subject: [PATCH 01/21] Ensure cache paths exist See https://github.com/wintercms/winter/commit/30ef051387d63867176550846bb989a595e0f502 --- src/Foundation/Application.php | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/Foundation/Application.php b/src/Foundation/Application.php index ea1028be4..8239f00c9 100644 --- a/src/Foundation/Application.php +++ b/src/Foundation/Application.php @@ -281,6 +281,25 @@ public function setMediaPath($path) return $this; } + /** + * Normalize a relative or absolute path to a cache file. + * + * @param string $key + * @param string $default + * @return string + */ + protected function normalizeCachePath($key, $default) + { + $path = parent::normalizeCachePath($key, $default); + + $directory = pathinfo($path, PATHINFO_DIRNAME); + if (!is_dir($directory)) { + mkdir($directory, 0755, true); + } + + return $path; + } + /** * Resolve the given type from the container. * From f59d437cbc3bdbe909d83e552b7093f6b9069a5d Mon Sep 17 00:00:00 2001 From: Luke Towers Date: Wed, 12 Jul 2023 21:17:54 -0600 Subject: [PATCH 02/21] Add support for event:cache, event:clear, and event:list commands The Event Dispatcher's getRawListeners() method has been changed to account for the fact that Winter's listeners are stored with priority and Laravel doesn't expect that. The event:listen command has also been modified to dig deeper into closures in order to correctly report their original locations. --- src/Events/Dispatcher.php | 21 ++++++++ src/Foundation/Console/EventListCommand.php | 53 +++++++++++++++++++ .../Providers/ArtisanServiceProvider.php | 6 +-- 3 files changed, 77 insertions(+), 3 deletions(-) create mode 100644 src/Foundation/Console/EventListCommand.php diff --git a/src/Events/Dispatcher.php b/src/Events/Dispatcher.php index 6f290884b..11f84e01d 100644 --- a/src/Events/Dispatcher.php +++ b/src/Events/Dispatcher.php @@ -167,6 +167,27 @@ public function dispatch($event, $payload = [], $halt = false) return $halt ? null : $responses; } + /** + * Gets the raw, unprepared listeners. + * + * @return array + */ + public function getRawListeners() + { + $listeners = []; + + foreach ($this->listeners as $event => $eventListeners) { + foreach ($eventListeners as $priority => $listenersByPriority) { + krsort($listenersByPriority); + foreach ($listenersByPriority as $listener) { + $listeners[$event][] = $listener; + } + } + } + + return $listeners; + } + /** * Get all of the listeners for a given event name. * diff --git a/src/Foundation/Console/EventListCommand.php b/src/Foundation/Console/EventListCommand.php new file mode 100644 index 000000000..0099e3b58 --- /dev/null +++ b/src/Foundation/Console/EventListCommand.php @@ -0,0 +1,53 @@ +getRawListeners() as $event => $rawListeners) { + foreach ($rawListeners as $rawListener) { + // Winter\Storm\Events\Dispatcher->makeListener() wraps closures in a SerializableClosure object + if ($rawListener instanceof SerializableClosure) { + $rawListener = $rawListener->getClosure(); + } + + // Illuminate\Events\Dispatcher->makeListener() wraps the original listener in a Closure + if ($rawListener instanceof Closure) { + $reflection = new ReflectionFunction($rawListener); + if ($reflection->getClosureCalledClass()?->getName() === Dispatcher::class) { + $rawListener = $reflection->getClosureUsedVariables()['listener'] ?? $rawListener; + } + } + + if (is_string($rawListener)) { + $events[$event][] = $this->appendListenerInterfaces($rawListener); + } elseif ($rawListener instanceof Closure) { + $events[$event][] = $this->stringifyClosure($rawListener); + } elseif (is_array($rawListener) && count($rawListener) === 2) { + if (is_object($rawListener[0])) { + $rawListener[0] = get_class($rawListener[0]); + } + + $events[$event][] = $this->appendListenerInterfaces(implode('@', $rawListener)); + } + } + } + + return $events; + } +} \ No newline at end of file diff --git a/src/Foundation/Providers/ArtisanServiceProvider.php b/src/Foundation/Providers/ArtisanServiceProvider.php index 3b6d348b4..351afed12 100644 --- a/src/Foundation/Providers/ArtisanServiceProvider.php +++ b/src/Foundation/Providers/ArtisanServiceProvider.php @@ -20,6 +20,9 @@ class ArtisanServiceProvider extends ArtisanServiceProviderBase 'ConfigClear' => \Illuminate\Foundation\Console\ConfigClearCommand::class, 'Down' => \Illuminate\Foundation\Console\DownCommand::class, 'Environment' => \Illuminate\Foundation\Console\EnvironmentCommand::class, + 'EventCache' => \Illuminate\Foundation\Console\EventCacheCommand::class, + 'EventClear' => \Illuminate\Foundation\Console\EventClearCommand::class, + 'EventList' => \Winter\Storm\Foundation\Console\EventListCommand::class, 'KeyGenerate' => \Winter\Storm\Foundation\Console\KeyGenerateCommand::class, 'Optimize' => \Illuminate\Foundation\Console\OptimizeCommand::class, 'PackageDiscover' => \Illuminate\Foundation\Console\PackageDiscoverCommand::class, @@ -48,9 +51,6 @@ class ArtisanServiceProvider extends ArtisanServiceProviderBase // 'Db' => DbCommand::class, // 'DbPrune' => PruneCommand::class, // 'DbWipe' => WipeCommand::class, - // 'EventCache' => EventCacheCommand::class, - // 'EventClear' => EventClearCommand::class, - // 'EventList' => EventListCommand::class, // 'OptimizeClear' => OptimizeClearCommand::class, // 'QueueClear' => QueueClearCommand::class, // 'SchemaDump' => DumpCommand::class, From 7b871c3a9180c4c88d176ce2d492cc9871767ffe Mon Sep 17 00:00:00 2001 From: Luke Towers Date: Thu, 13 Jul 2023 07:36:15 -0600 Subject: [PATCH 03/21] Add newline to end of file --- src/Foundation/Console/EventListCommand.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Foundation/Console/EventListCommand.php b/src/Foundation/Console/EventListCommand.php index 0099e3b58..b972866af 100644 --- a/src/Foundation/Console/EventListCommand.php +++ b/src/Foundation/Console/EventListCommand.php @@ -50,4 +50,4 @@ protected function getListenersOnDispatcher() return $events; } -} \ No newline at end of file +} From 6979440da61a146b1277dc86c6b382204d3c579a Mon Sep 17 00:00:00 2001 From: Luke Towers Date: Fri, 14 Jul 2023 00:00:49 -0600 Subject: [PATCH 04/21] Added App::hasDatabaseTable() helper method --- src/Foundation/Application.php | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/Foundation/Application.php b/src/Foundation/Application.php index 8239f00c9..a550c4745 100644 --- a/src/Foundation/Application.php +++ b/src/Foundation/Application.php @@ -381,20 +381,26 @@ public function runningInBackend() /** * Returns true if a database connection is present. - * @return boolean */ - public function hasDatabase() + public function hasDatabase(): bool { try { $this['db.connection']->getPdo(); - } - catch (Throwable $ex) { + } catch (Throwable $ex) { return false; } return true; } + /** + * Checks if the provided table is present on the default database connection. + */ + public function hasDatabaseTable(string $table): bool + { + return $this->hasDatabase() && $this['db.connection']->getSchemaBuilder()->hasTable($table); + } + /** * Set the current application locale. * @param string $locale From 162b8e89c7ef3e09e98e5d9a8f676bfe48434271 Mon Sep 17 00:00:00 2001 From: Luke Towers Date: Fri, 14 Jul 2023 00:07:57 -0600 Subject: [PATCH 05/21] Added Model::hasDatabaseTable() and $model->isDatabaseReady() helpers --- src/Database/Model.php | 57 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/src/Database/Model.php b/src/Database/Model.php index e1013b56b..efa5e4d98 100644 --- a/src/Database/Model.php +++ b/src/Database/Model.php @@ -1,13 +1,15 @@ fill($attributes); } + /** + * Static helper for isDatabaseReady() + */ + public static function hasDatabaseTable(): bool + { + return (new static)->isDatabaseReady(); + } + + /** + * Check if the model's database connection is ready + */ + public function isDatabaseReady(): bool + { + $cacheKey = sprintf('winter.storm::model.%s.isDatabaseReady.%s.%s', get_class($this), $this->getConnectionName() ?? '', $this->getTable()); + if ($result = Cache::get($cacheKey)) { + return $result; + } + + // Resolver hasn't been set yet + if (!static::getConnectionResolver()) { + return false; + } + + // Connection hasn't been set yet or the database doesn't exist + try { + $connection = $this->getConnection(); + $connection->getPdo(); + } catch (Throwable $ex) { + return false; + } + + // Database exists but table doesn't + try { + $schema = $connection->getSchemaBuilder(); + $table = $this->getTable(); + if (!$schema->hasTable($table)) { + return false; + } + } catch (Throwable $ex) { + return false; + } + + Cache::forever($cacheKey, true); + + return true; + } + /** * Create a new model and return the instance. * @param array $attributes From ae124b18018be4413fe89cd7e6bad5eb1186b10f Mon Sep 17 00:00:00 2001 From: Luke Towers Date: Fri, 14 Jul 2023 15:15:07 -0600 Subject: [PATCH 06/21] Code tidying --- src/Console/Command.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Console/Command.php b/src/Console/Command.php index 3f8079112..3aa4d6aaf 100644 --- a/src/Console/Command.php +++ b/src/Console/Command.php @@ -14,6 +14,11 @@ abstract class Command extends BaseCommand implements SignalableCommandInterface use Traits\HandlesCleanup; use Traits\ProvidesAutocompletion; + /** + * @var \Winter\Storm\Foundation\Application + */ + protected $laravel; + /** * @var array List of commands that this command replaces (aliases) */ From dd7b1b68446afe32a819a31faba15b965c344950 Mon Sep 17 00:00:00 2001 From: Luke Towers Date: Fri, 14 Jul 2023 15:39:55 -0600 Subject: [PATCH 07/21] Disable specific phpstan complaint for now Related: https://github.com/laravel/framework/pull/47749 --- src/Database/Model.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Database/Model.php b/src/Database/Model.php index efa5e4d98..974ccd4dd 100644 --- a/src/Database/Model.php +++ b/src/Database/Model.php @@ -106,6 +106,7 @@ public function isDatabaseReady(): bool } // Resolver hasn't been set yet + /** @phpstan-ignore-next-line */ if (!static::getConnectionResolver()) { return false; } From 41172a383e3b4ce3893b677652995dcad393949c Mon Sep 17 00:00:00 2001 From: Luke Towers Date: Wed, 19 Jul 2023 19:46:32 -0600 Subject: [PATCH 08/21] Fix issue with open_basedir compatibility Should fix wintercms/winter#948 --- src/Filesystem/Filesystem.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Filesystem/Filesystem.php b/src/Filesystem/Filesystem.php index 3eb3542ad..9904402b9 100644 --- a/src/Filesystem/Filesystem.php +++ b/src/Filesystem/Filesystem.php @@ -431,8 +431,8 @@ protected function findSymlinks(): void $iterator = function ($path) use (&$iterator, &$symlinks, $basePath, $restrictBaseDir, $deep) { foreach (new DirectoryIterator($path) as $directory) { if ( - $directory->isDir() === false - || $directory->isDot() === true + $directory->isDot() + || !$directory->isDir() ) { continue; } From b9a7c98bf97e0c01485caa6ed2606e5e898f0534 Mon Sep 17 00:00:00 2001 From: Luke Towers Date: Sat, 22 Jul 2023 21:48:26 -0600 Subject: [PATCH 09/21] Add hasAttribute() method to base Model class --- src/Database/Model.php | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/Database/Model.php b/src/Database/Model.php index 974ccd4dd..5e701aa2e 100644 --- a/src/Database/Model.php +++ b/src/Database/Model.php @@ -1094,6 +1094,20 @@ public function addJsonable($attributes = null) // Getters // + /** + * Determine if the given attribute will be processed by getAttributeValue(). + */ + public function hasAttribute(string $key): bool + { + return ( + array_key_exists($key, $this->attributes) + || array_key_exists($key, $this->casts) + || $this->hasGetMutator($key) + || $this->hasAttributeMutator($key) + || $this->isClassCastable($key) + ); + } + /** * Get an attribute from the model. * Overrides {@link Eloquent} to support loading from property-defined relations. @@ -1110,13 +1124,7 @@ public function getAttribute($key) // If the attribute exists in the attribute array or has a "get" mutator we will // get the attribute's value. Otherwise, we will proceed as if the developers // are asking for a relationship's value. This covers both types of values. - if ( - array_key_exists($key, $this->attributes) - || array_key_exists($key, $this->casts) - || $this->hasGetMutator($key) - || $this->hasAttributeMutator($key) - || $this->isClassCastable($key) - ) { + if ($this->hasAttribute($key)) { return $this->getAttributeValue($key); } From ce542acdb6ad2df897fd1b33eb7a2bc8891ffd05 Mon Sep 17 00:00:00 2001 From: Marc Jauvin Date: Tue, 1 Aug 2023 21:45:04 -0400 Subject: [PATCH 10/21] Add Http->json() helper method to send the provided payload as JSON (#147) Co-authored-by: Ben Thomson Co-authored-by: Luke Towers --- src/Network/Http.php | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/src/Network/Http.php b/src/Network/Http.php index fc9c4943e..693365aa4 100644 --- a/src/Network/Http.php +++ b/src/Network/Http.php @@ -1,6 +1,7 @@ requestData = $payload; + $this->header('Content-Type', 'application/json'); + + return $this; + } + /** * Add a data to the request. - * @param string $value - * @return self */ - public function data($key, $value = null) + public function data(array|string $key, string $value = null): self { if (is_array($key)) { foreach ($key as $_key => $_value) { $this->data($_key, $_value); } - return $this; + } else { + $this->requestData[$key] = $value; } - - $this->requestData[$key] = $value; return $this; } From 4665d665cb4611f7c8c7237b07fad0e6b73d9f4c Mon Sep 17 00:00:00 2001 From: Jack Wilkinson <31214002+jaxwilko@users.noreply.github.com> Date: Thu, 10 Aug 2023 02:22:29 +0100 Subject: [PATCH 11/21] Update tests for config writer v1.1.0 (#151) --- tests/Parse/EnvFileTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Parse/EnvFileTest.php b/tests/Parse/EnvFileTest.php index 48412ce36..5afe08157 100644 --- a/tests/Parse/EnvFileTest.php +++ b/tests/Parse/EnvFileTest.php @@ -90,7 +90,7 @@ public function testWriteFileWithUpdatesArray() $this->assertStringContainsString('APP_KEY="winter"', $result); $this->assertStringContainsString('DB_USE_CONFIG_FOR_TESTING=false', $result); $this->assertStringContainsString('MAIL_HOST="smtp.mailgun.org"', $result); - $this->assertStringContainsString('ROUTES_CACHE="winter"', $result); + $this->assertStringContainsString('ROUTES_CACHE=winter', $result); $this->assertStringContainsString('ENABLE_CSRF=true', $result); $this->assertStringContainsString('# HELLO WORLD', $result); $this->assertStringContainsString('#ENV_TEST="wintercms"', $result); @@ -155,7 +155,7 @@ public function testCasting() $env->write($tmpFile); $result = file_get_contents($tmpFile); - $this->assertStringContainsString('APP_KEY=123', $result); + $this->assertStringContainsString('APP_KEY="123"', $result); $env->set(['APP_KEY' => true]); $env->write($tmpFile); From 4f683cc3f1e001fc66c41a708a888985ef89497e Mon Sep 17 00:00:00 2001 From: Marc Jauvin Date: Sun, 13 Aug 2023 09:24:39 -0400 Subject: [PATCH 12/21] Call model methods using event handler with default priority (#150) Replaces #145 This preserves current behaviour: an event listener defined on the model with default priority (0) will get called first, then the model method will get called (through an event listener with default priority). If an event listener uses a LOWER priority (e.g. -1), the model method will get called first (because its priority is 0 by default) --- phpstan-baseline.neon | 30 ------------ src/Database/Model.php | 10 ++-- src/Database/Traits/SoftDelete.php | 20 ++++---- src/Database/Traits/Validation.php | 77 +++++++++++++++++------------- src/Halcyon/Model.php | 10 ++-- 5 files changed, 70 insertions(+), 77 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index c3a9cf686..90ef336b2 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -10,16 +10,6 @@ parameters: count: 1 path: src/Auth/Models/Group.php - - - message: "#^Call to an undefined method Winter\\\\Storm\\\\Auth\\\\Models\\\\Group\\:\\:afterValidate\\(\\)\\.$#" - count: 1 - path: src/Auth/Models/Group.php - - - - message: "#^Call to an undefined method Winter\\\\Storm\\\\Auth\\\\Models\\\\Group\\:\\:beforeValidate\\(\\)\\.$#" - count: 1 - path: src/Auth/Models/Group.php - - message: "#^Call to an undefined method \\$this\\(Winter\\\\Storm\\\\Auth\\\\Models\\\\Role\\)\\:\\:getOriginalEncryptableValues\\(\\)\\.$#" count: 1 @@ -30,31 +20,11 @@ parameters: count: 1 path: src/Auth/Models/Role.php - - - message: "#^Call to an undefined method Winter\\\\Storm\\\\Auth\\\\Models\\\\Role\\:\\:afterValidate\\(\\)\\.$#" - count: 1 - path: src/Auth/Models/Role.php - - - - message: "#^Call to an undefined method Winter\\\\Storm\\\\Auth\\\\Models\\\\Role\\:\\:beforeValidate\\(\\)\\.$#" - count: 1 - path: src/Auth/Models/Role.php - - message: "#^Call to an undefined method \\$this\\(Winter\\\\Storm\\\\Auth\\\\Models\\\\User\\)\\:\\:getOriginalEncryptableValues\\(\\)\\.$#" count: 1 path: src/Auth/Models/User.php - - - message: "#^Call to an undefined method Winter\\\\Storm\\\\Auth\\\\Models\\\\User\\:\\:afterValidate\\(\\)\\.$#" - count: 1 - path: src/Auth/Models/User.php - - - - message: "#^Call to an undefined method Winter\\\\Storm\\\\Auth\\\\Models\\\\User\\:\\:beforeValidate\\(\\)\\.$#" - count: 1 - path: src/Auth/Models/User.php - - message: "#^Parameter \\#1 \\$app of class Illuminate\\\\Database\\\\DatabaseManager constructor expects Illuminate\\\\Contracts\\\\Foundation\\\\Application, Illuminate\\\\Contracts\\\\Container\\\\Container given\\.$#" count: 1 diff --git a/src/Database/Model.php b/src/Database/Model.php index 5e701aa2e..073566e0c 100644 --- a/src/Database/Model.php +++ b/src/Database/Model.php @@ -226,11 +226,15 @@ protected function bootNicerEvents() } self::$eventMethod(function ($model) use ($method) { - $model->fireEvent('model.' . $method); - if ($model->methodExists($method)) { - return $model->$method(); + // Register the method as a listener with default priority + // to allow for complete control over the execution order + $model->bindEvent('model.' . $method, [$model, $method]); } + // First listener that returns a non-null result will cancel the + // further propagation of the event; If that result is false, the + // underlying action will get cancelled (e.g. creating, saving, deleting) + return $model->fireEvent('model.' . $method, halt: true); }); } } diff --git a/src/Database/Traits/SoftDelete.php b/src/Database/Traits/SoftDelete.php index 1b7243514..cf19758d9 100644 --- a/src/Database/Traits/SoftDelete.php +++ b/src/Database/Traits/SoftDelete.php @@ -24,6 +24,11 @@ public static function bootSoftDelete() static::addGlobalScope(new SoftDeletingScope); static::restoring(function ($model) { + if ($model->methodExists('beforeRestore')) { + // Register the method as a listener with default priority + // to allow for complete control over the execution order + $model->bindEvent('model.beforeRestore', [$model, 'beforeRestore']); + } /** * @event model.beforeRestore * Called before the model is restored from a soft delete @@ -35,13 +40,15 @@ public static function bootSoftDelete() * }); * */ - $model->fireEvent('model.beforeRestore'); - if ($model->methodExists('beforeRestore')) { - $model->beforeRestore(); - } + return $model->fireEvent('model.beforeRestore', halt: true); }); static::restored(function ($model) { + if ($model->methodExists('afterRestore')) { + // Register the method as a listener with default priority + // to allow for complete control over the execution order + $model->bindEvent('model.afterRestore', [$model, 'afterRestore']); + } /** * @event model.afterRestore * Called after the model is restored from a soft delete @@ -53,10 +60,7 @@ public static function bootSoftDelete() * }); * */ - $model->fireEvent('model.afterRestore'); - if ($model->methodExists('afterRestore')) { - $model->afterRestore(); - } + return $model->fireEvent('model.afterRestore', halt: true); }); } diff --git a/src/Database/Traits/Validation.php b/src/Database/Traits/Validation.php index a4f68504b..6270a1cdc 100644 --- a/src/Database/Traits/Validation.php +++ b/src/Database/Traits/Validation.php @@ -61,6 +61,49 @@ public static function bootValidation() )); } + static::validating(function ($model) { + if ($model->methodExists('beforeValidate')) { + // Register the method as a listener with default priority + // to allow for complete control over the execution order + $model->bindEvent('model.beforeValidate', [$model, 'beforeValidate']); + } + + /** + * @event model.beforeValidate + * Called before the model is validated + * + * Example usage: + * + * $model->bindEvent('model.beforeValidate', function () use (\Winter\Storm\Database\Model $model) { + * // Prevent anything from validating ever! + * return false; + * }); + * + */ + return $model->fireEvent('model.beforeValidate', halt: true); + }); + + static::validated(function ($model) { + if ($model->methodExists('afterValidate')) { + // Register the method as a listener with default priority + // to allow for complete control over the execution order + $model->bindEvent('model.afterValidate', [$model, 'afterValidate']); + } + + /** + * @event model.afterValidate + * Called after the model is validated + * + * Example usage: + * + * $model->bindEvent('model.afterValidate', function () use (\Winter\Storm\Database\Model $model) { + * \Log::info("{$model->name} successfully passed validation"); + * }); + * + */ + return $model->fireEvent('model.afterValidate', halt: true); + }); + static::extend(function ($model) { $model->bindEvent('model.saveInternal', function ($data, $options) use ($model) { /* @@ -174,19 +217,7 @@ public function validate($rules = null, $customMessages = null, $attributeNames ? $this->throwOnValidation : true; - /** - * @event model.beforeValidate - * Called before the model is validated - * - * Example usage: - * - * $model->bindEvent('model.beforeValidate', function () use (\Winter\Storm\Database\Model $model) { - * // Prevent anything from validating ever! - * return false; - * }); - * - */ - if (($this->fireModelEvent('validating') === false) || ($this->fireEvent('model.beforeValidate', [], true) === false)) { + if ($this->fireModelEvent('validating') === false) { if ($throwOnValidation) { throw new ModelException($this); } @@ -194,10 +225,6 @@ public function validate($rules = null, $customMessages = null, $attributeNames return false; } - if ($this->methodExists('beforeValidate')) { - $this->beforeValidate(); - } - /* * Perform validation */ @@ -323,23 +350,7 @@ public function validate($rules = null, $customMessages = null, $attributeNames } } - /** - * @event model.afterValidate - * Called after the model is validated - * - * Example usage: - * - * $model->bindEvent('model.afterValidate', function () use (\Winter\Storm\Database\Model $model) { - * \Log::info("{$model->name} successfully passed validation"); - * }); - * - */ $this->fireModelEvent('validated', false); - $this->fireEvent('model.afterValidate'); - - if ($this->methodExists('afterValidate')) { - $this->afterValidate(); - } if (!$success && $throwOnValidation) { throw new ModelException($this); diff --git a/src/Halcyon/Model.php b/src/Halcyon/Model.php index 65ca0ee4e..c130823ad 100644 --- a/src/Halcyon/Model.php +++ b/src/Halcyon/Model.php @@ -239,11 +239,15 @@ protected function bootNicerEvents() } self::$eventMethod(function ($model) use ($method) { - $model->fireEvent('model.' . $method); - if ($model->methodExists($method)) { - return $model->$method(); + // Register the method as a listener with default priority + // to allow for complete control over the execution order + $model->bindEvent('model.' . $method, [$model, $method]); } + // First listener that returns a non-null result will cancel the + // further propagation of the event; If that result is false, the + // underlying action will get cancelled (e.g. creating, saving, deleting) + return $model->fireEvent('model.' . $method, halt: true); }); } } From 391bd3e10c6c6fa0a511596a0d7ef9a4b68ec513 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20pavez?= Date: Tue, 22 Aug 2023 03:20:25 -0400 Subject: [PATCH 13/21] Clear model cache when using upsert (#154) Currently a memory cache is used to avoid making unnecessary calls to the database, when there have been no changes. This cache is cleared by executing insert, update, delete or truncate. With this change, the cache is also cleared when using upsert. --- src/Database/Builder.php | 2 + src/Database/QueryBuilder.php | 2 + tests/Database/ModelTest.php | 71 +++++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+) diff --git a/src/Database/Builder.php b/src/Database/Builder.php index 2d760194d..f42dcf2a2 100644 --- a/src/Database/Builder.php +++ b/src/Database/Builder.php @@ -222,6 +222,8 @@ public function upsert(array $values, $uniqueBy, $update = null) return 0; } + $this->clearDuplicateCache(); + if (!is_array(reset($values))) { $values = [$values]; } diff --git a/src/Database/QueryBuilder.php b/src/Database/QueryBuilder.php index 9126576ad..14289c59c 100644 --- a/src/Database/QueryBuilder.php +++ b/src/Database/QueryBuilder.php @@ -311,6 +311,8 @@ public function upsert(array $values, $uniqueBy, $update = null) return 0; } + $this->clearDuplicateCache(); + if ($update === []) { return (int) $this->insert($values); } diff --git a/tests/Database/ModelTest.php b/tests/Database/ModelTest.php index f704f7e16..b2a4b8b2a 100644 --- a/tests/Database/ModelTest.php +++ b/tests/Database/ModelTest.php @@ -132,6 +132,63 @@ public function testHiddenAttributes() $this->assertArrayNotHasKey('description', $model->toArray()); } + public function testUpsert() + { + $this->getBuilder()->create('test_model2', function ($table) { + $table->increments('id'); + $table->string('name')->nullable(); + $table->timestamps(); + }); + + $this->getBuilder()->create('test_model_middle', function ($table) { + $table->increments('id'); + $table->string('value')->nullable(); + $table->timestamps(); + + $table->integer('model1_id')->unsigned(); + $table->integer('model2_id')->unsigned(); + + $table->foreign('model1_id')->references('id')->on('test_model1'); + $table->foreign('model2_id')->references('id')->on('test_model2'); + $table->unique(['model1_id', 'model2_id']); + }); + + $model1Row = TestModelGuarded::create([ + 'name' => 'Row 1', + 'data' => 'Test data' + ]); + + $model2Row = TestModel2::create([ + 'name' => 'Test', + ]); + + $test3Row = TestModelMiddle::create([ + 'model1_id' => $model1Row->id, + 'model2_id' => $model2Row->id, + 'value' => '1' + ]); + + TestModelMiddle::upsert([ + 'model1_id' => $model1Row->id, + 'model2_id' => $model2Row->id, + 'value' => '1' + ], ['model1_id', 'model2_id'], ['value']); + + $modelMiddleRow = TestModelMiddle::first(); + + $this->assertEquals('1', $modelMiddleRow->value); + + TestModelMiddle::upsert([ + 'model1_id' => $model1Row->id, + 'model2_id' => $model2Row->id, + 'value' => '2' + ], ['model1_id', 'model2_id'], ['value']); + + $modelMiddleRow = TestModelMiddle::first(); + + $this->assertEquals('2', $modelMiddleRow->value); + } + protected function createTable() { $this->getBuilder()->create('test_model', function ($table) { @@ -199,3 +256,17 @@ class TestModelHidden extends Model public $table = 'test_model'; } + +class TestModel2 extends Model +{ + protected $guarded = []; + + public $table = 'test_model2'; +} + +class TestModelMiddle extends Model +{ + protected $guarded = []; + + public $table = 'test_model_middle'; +} From de146af339aeaac77ac1c0d0246c09f5ef169cac Mon Sep 17 00:00:00 2001 From: der_On Date: Fri, 15 Sep 2023 02:16:23 +0200 Subject: [PATCH 14/21] Adds Encryptable model behavior (#138) --- src/Database/Behaviors/Encryptable.php | 158 ++++++++++++++++++ src/Database/Model.php | 2 +- .../Behaviors/EncryptableBehaviorTest.php | 71 ++++++++ 3 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 src/Database/Behaviors/Encryptable.php create mode 100644 tests/Database/Behaviors/EncryptableBehaviorTest.php diff --git a/src/Database/Behaviors/Encryptable.php b/src/Database/Behaviors/Encryptable.php new file mode 100644 index 000000000..9f525a234 --- /dev/null +++ b/src/Database/Behaviors/Encryptable.php @@ -0,0 +1,158 @@ +addDynamicProperty('encryptable', ['encrypt_this']); + * $model->extendClassWith(\Winter\Storm\Database\Behaviors\Encryptable::class); + * }); + * + * >**NOTE**: Encrypted attributes will be serialized and unserialized + * as a part of the encryption / decryption process. Do not make an + * attribute that is encryptable also jsonable at the same time as the + * jsonable process will attempt to decode a value that has already been + * unserialized by the encrypter. + * + */ +class Encryptable extends ExtensionBase +{ + protected Model $model; + + /** + * List of attribute names which should be encrypted + * + * protected array $encryptable = []; + */ + + /** + * Encrypter instance. + */ + protected ?Encrypter $encrypterInstance = null; + + /** + * List of original attribute values before they were encrypted. + */ + protected array $originalEncryptableValues = []; + + public function __construct($parent) + { + $this->model = $parent; + $this->bootEncryptable(); + } + + /** + * Boot the encryptable trait for a model. + */ + public function bootEncryptable(): void + { + $isEncryptable = $this->model->extend(function () { + /** @var Model $this */ + return $this->propertyExists('encryptable'); + }); + + if (!$isEncryptable) { + throw new ApplicationException(sprintf( + 'You must define an $encryptable property on the %s class to use the Encryptable behavior.', + get_class($this->model) + )); + } + + /* + * Encrypt required fields when necessary + */ + $this->model->bindEvent('model.beforeSetAttribute', function ($key, $value) { + if (in_array($key, $this->getEncryptableAttributes()) && !is_null($value)) { + return $this->makeEncryptableValue($key, $value); + } + }); + $this->model->bindEvent('model.beforeGetAttribute', function ($key) { + if (in_array($key, $this->getEncryptableAttributes()) && array_get($this->model->attributes, $key) != null) { + return $this->getEncryptableValue($key); + } + }); + } + + /** + * Encrypts an attribute value and saves it in the original locker. + */ + public function makeEncryptableValue(string $key, mixed $value): string + { + $this->originalEncryptableValues[$key] = $value; + return $this->getEncrypter()->encrypt($value); + } + + /** + * Decrypts an attribute value + */ + public function getEncryptableValue(string $key): mixed + { + $attributes = $this->model->getAttributes(); + return isset($attributes[$key]) + ? $this->getEncrypter()->decrypt($attributes[$key]) + : null; + } + + /** + * Returns a collection of fields that will be encrypted. + */ + public function getEncryptableAttributes(): array + { + return $this->model->extend(function () { + return $this->encryptable ?? []; + }); + } + + /** + * Returns the original values of any encrypted attributes. + */ + public function getOriginalEncryptableValues(): array + { + return $this->originalEncryptableValues; + } + + /** + * Returns the original values of any encrypted attributes. + */ + public function getOriginalEncryptableValue(string $attribute): mixed + { + return array_get($this->originalEncryptableValues, $attribute, null); + } + + /** + * Provides the encrypter instance. + */ + public function getEncrypter(): Encrypter + { + return (!is_null($this->encrypterInstance)) ? $this->encrypterInstance : App::make('encrypter'); + } + + /** + * Sets the encrypter instance. + */ + public function setEncrypter(Encrypter $encrypter): void + { + $this->encrypterInstance = $encrypter; + } +} diff --git a/src/Database/Model.php b/src/Database/Model.php index 073566e0c..7e436569c 100644 --- a/src/Database/Model.php +++ b/src/Database/Model.php @@ -19,7 +19,7 @@ * @author Alexey Bobkov, Samuel Georges * * @phpstan-property \Illuminate\Contracts\Events\Dispatcher|null $dispatcher - * @method static void extend(callable $callback, bool $scoped = false, ?object $outerScope = null) + * @method static mixed extend(callable $callback, bool $scoped = false, ?object $outerScope = null) */ class Model extends EloquentModel implements ModelInterface { diff --git a/tests/Database/Behaviors/EncryptableBehaviorTest.php b/tests/Database/Behaviors/EncryptableBehaviorTest.php new file mode 100644 index 000000000..54dfd4a86 --- /dev/null +++ b/tests/Database/Behaviors/EncryptableBehaviorTest.php @@ -0,0 +1,71 @@ +createTable(); + + $this->encrypter = new Encrypter(self::TEST_CRYPT_KEY, 'AES-128-CBC'); + } + + public function testEncryptableBehavior() + { + $testModel = new TestModelEncryptableBehavior(); + $testModel->setEncrypter($this->encrypter); + + $testModel->fill(['secret' => 'test']); + $this->assertEquals('test', $testModel->secret); + $this->assertNotEquals('test', $testModel->attributes['secret']); + $payloadOne = json_decode(base64_decode($testModel->attributes['secret']), true); + $this->assertEquals(['iv', 'value', 'mac', 'tag'], array_keys($payloadOne)); + + $testModel->secret = ''; + $this->assertEquals('', $testModel->secret); + $this->assertNotEquals('', $testModel->attributes['secret']); + $payloadTwo = json_decode(base64_decode($testModel->attributes['secret']), true); + $this->assertEquals(['iv', 'value', 'mac', 'tag'], array_keys($payloadTwo)); + $this->assertNotEquals($payloadOne['value'], $payloadTwo['value']); + + $testModel->secret = 0; + $this->assertEquals(0, $testModel->secret); + $this->assertNotEquals(0, $testModel->attributes['secret']); + $payloadThree = json_decode(base64_decode($testModel->attributes['secret']), true); + $this->assertEquals(['iv', 'value', 'mac', 'tag'], array_keys($payloadThree)); + $this->assertNotEquals($payloadTwo['value'], $payloadThree['value']); + + $testModel->secret = null; + $this->assertNull($testModel->secret); + $this->assertNull($testModel->attributes['secret']); + } + + protected function createTable() + { + $this->getBuilder()->create('secrets', function ($table) { + $table->increments('id'); + $table->string('secret'); + $table->timestamps(); + }); + } +} + +class TestModelEncryptableBehavior extends \Winter\Storm\Database\Model +{ + public $implement = [ + \Winter\Storm\Database\Behaviors\Encryptable::class, + ]; + + protected $encryptable = ['secret']; + protected $fillable = ['secret']; + protected $table = 'secrets'; +} From 5a3ffdc4bd2561f1333be6b173570be907dde227 Mon Sep 17 00:00:00 2001 From: AIC BV <89913092+AIC-BV@users.noreply.github.com> Date: Sat, 14 Oct 2023 20:46:13 +0200 Subject: [PATCH 15/21] Fix docblocks for Resizer class (#158) --- src/Database/Attach/Resizer.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Database/Attach/Resizer.php b/src/Database/Attach/Resizer.php index 3851c10c1..68f43c0d3 100644 --- a/src/Database/Attach/Resizer.php +++ b/src/Database/Attach/Resizer.php @@ -10,16 +10,19 @@ * Usage: * Resizer::open(mixed $file) * ->resize(int $width , int $height, string 'exact, portrait, landscape, auto, fit or crop') - * ->save(string 'path/to/file.jpg', int $quality); + * ->setOptions(['quality' => int $quality]) + * ->save(string 'path/to/file.jpg'); * * // Resize and save an image. * Resizer::open(Input::file('field_name')) * ->resize(800, 600, 'crop') - * ->save('path/to/file.jpg', 100); + * ->setOptions(['quality' => 100]) + * ->save('path/to/file.jpg'); * * // Recompress an image. * Resizer::open('path/to/image.jpg') - * ->save('path/to/new_image.jpg', 60); + * ->setOptions(['quality' => 60]) + * ->save('path/to/new_image.jpg'); * * @author Alexey Bobkov, Samuel Georges */ From c0acc2b24516e2c7208f4303de68bb23699bbd87 Mon Sep 17 00:00:00 2001 From: Marc Jauvin Date: Sun, 15 Oct 2023 22:07:32 -0400 Subject: [PATCH 16/21] Add event to make model validation extendable (#139) Refs: https://github.com/wintercms/wn-translate-plugin/pull/52 --- src/Database/Traits/Validation.php | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/Database/Traits/Validation.php b/src/Database/Traits/Validation.php index 6270a1cdc..ee3ca8ba6 100644 --- a/src/Database/Traits/Validation.php +++ b/src/Database/Traits/Validation.php @@ -154,7 +154,27 @@ public function setValidationAttributeName($attr, $name) */ protected function getValidationAttributes() { - return $this->getAttributes(); + $attributes = $this->getAttributes(); + + /** + * @event model.getValidationAttributes + * Called when fetching the model attributes to validate the model + * + * Example usage from TranslatableBehavior class: + * + * $model->bindEvent('model.getValidationAttributes', function ($attributes) { + * $locale = $this->translateContext(); + * if ($locale !== $this->translatableDefault) { + * return array_merge($attributes, $this->getTranslateDirty($locale)); + * } + * }); + * + */ + if (($validationAttributes = $this->fireEvent('model.getValidationAttributes', [$attributes], true)) !== null) { + return $validationAttributes; + } + + return $attributes; } /** From 404b199dc9735a71bffa6d1fd40ff48e16efbead Mon Sep 17 00:00:00 2001 From: Marc Jauvin Date: Tue, 17 Oct 2023 21:30:48 -0400 Subject: [PATCH 17/21] Improve automatic detaching / deletion of relations (#156) Adds support for automatic detaching / deletion of relationships using pivot tables. Related: wintercms/winter#386, wintercms/winter#972. Documented by https://github.com/wintercms/docs/pull/159. Co-authored-by: Ben Thomson Co-authored-by: Luke Towers --- src/Auth/Models/User.php | 13 +- src/Database/Model.php | 37 +- src/Database/Traits/SoftDelete.php | 69 ++- tests/Database/ModelTest.php | 549 ++++++++++++++++++++++- tests/Database/Traits/SoftDeleteTest.php | 131 ++++++ 5 files changed, 730 insertions(+), 69 deletions(-) create mode 100644 tests/Database/Traits/SoftDeleteTest.php diff --git a/src/Auth/Models/User.php b/src/Auth/Models/User.php index 8e7f020ad..3865d0a9f 100644 --- a/src/Auth/Models/User.php +++ b/src/Auth/Models/User.php @@ -39,7 +39,7 @@ class User extends Model implements \Illuminate\Contracts\Auth\Authenticatable * @var array Relations */ public $belongsToMany = [ - 'groups' => [Group::class, 'table' => 'users_groups'] + 'groups' => [Group::class, 'table' => 'users_groups'], ]; public $belongsTo = [ @@ -154,17 +154,6 @@ public function afterLogin() $this->forceSave(); } - /** - * Delete the user groups - * @return void - */ - public function afterDelete() - { - if ($this->hasRelation('groups')) { - $this->groups()->detach(); - } - } - // // Persistence (used by Cookies and Sessions) // diff --git a/src/Database/Model.php b/src/Database/Model.php index 7e436569c..db68736c7 100644 --- a/src/Database/Model.php +++ b/src/Database/Model.php @@ -998,6 +998,7 @@ protected function performDeleteOnModel() /** * Locates relations with delete flag and cascades the delete event. + * For pivot relations, detach the pivot record unless the detach flag is false. * @return void */ protected function performDeleteOnRelations() @@ -1008,32 +1009,30 @@ protected function performDeleteOnRelations() * Hard 'delete' definition */ foreach ($relations as $name => $options) { - if (!Arr::get($options, 'delete', false)) { - continue; - } - if (!$relation = $this->{$name}) { continue; } - if ($relation instanceof EloquentModel) { - $relation->forceDelete(); - } - elseif ($relation instanceof CollectionBase) { - $relation->each(function ($model) { - $model->forceDelete(); - }); - } - } - - /* - * Belongs-To-Many should clean up after itself always - */ - if ($type == 'belongsToMany') { - foreach ($relations as $name => $options) { + if (in_array($type, ['belongsToMany', 'morphToMany', 'morphedByMany'])) { + // we want to remove the pivot record, not the actual relation record if (Arr::get($options, 'detach', true)) { $this->{$name}()->detach(); } + } elseif (in_array($type, ['belongsTo', 'hasOneThrough', 'hasManyThrough', 'morphTo'])) { + // the model does not own the related record, we should not remove it. + continue; + } elseif (in_array($type, ['attachOne', 'attachMany', 'hasOne', 'hasMany', 'morphOne', 'morphMany'])) { + if (!Arr::get($options, 'delete', false)) { + continue; + } + + if ($relation instanceof EloquentModel) { + $relation->forceDelete(); + } elseif ($relation instanceof CollectionBase) { + $relation->each(function ($model) { + $model->forceDelete(); + }); + } } } } diff --git a/src/Database/Traits/SoftDelete.php b/src/Database/Traits/SoftDelete.php index cf19758d9..fc913b8e8 100644 --- a/src/Database/Traits/SoftDelete.php +++ b/src/Database/Traits/SoftDelete.php @@ -115,21 +115,27 @@ protected function performSoftDeleteOnRelations() $definitions = $this->getRelationDefinitions(); foreach ($definitions as $type => $relations) { foreach ($relations as $name => $options) { - if (!array_get($options, 'softDelete', false)) { - continue; - } - if (!$relation = $this->{$name}) { continue; } - - if ($relation instanceof EloquentModel) { - $relation->delete(); + if (!array_get($options, 'softDelete', false)) { + continue; } - elseif ($relation instanceof CollectionBase) { - $relation->each(function ($model) { - $model->delete(); - }); + if (in_array($type, ['belongsToMany', 'morphToMany', 'morphedByMany'])) { + // relations using pivot table + $value = $this->fromDateTime($this->freshTimestamp()); + $this->updatePivotDeletedAtColumn($name, $options, $value); + } elseif (in_array($type, ['belongsTo', 'hasOneThrough', 'hasManyThrough', 'morphTo'])) { + // the model does not own the related record, we should not remove it. + continue; + } elseif (in_array($type, ['attachOne', 'attachMany', 'hasOne', 'hasMany', 'morphOne', 'morphMany'])) { + if ($relation instanceof EloquentModel) { + $relation->delete(); + } elseif ($relation instanceof CollectionBase) { + $relation->each(function ($model) { + $model->delete(); + }); + } } } } @@ -177,6 +183,19 @@ public function restore() return $result; } + /** + * Update relation pivot table deleted_at column + */ + protected function updatePivotDeletedAtColumn(string $relationName, array $options, string|null $value) + { + // get deletedAtColumn from the relation options, otherwise use default + $deletedAtColumn = array_get($options, 'deletedAtColumn', 'deleted_at'); + + $this->{$relationName}()->newPivotQuery()->update([ + $deletedAtColumn => $value, + ]); + } + /** * Locates relations with softDelete flag and cascades the restore event. * @@ -191,18 +210,22 @@ protected function performRestoreOnRelations() continue; } - $relation = $this->{$name}()->onlyTrashed()->getResults(); - if (!$relation) { - continue; - } - - if ($relation instanceof EloquentModel) { - $relation->restore(); - } - elseif ($relation instanceof CollectionBase) { - $relation->each(function ($model) { - $model->restore(); - }); + if (in_array($type, ['belongsToMany', 'morphToMany', 'morphedByMany'])) { + // relations using pivot table + $this->updatePivotDeletedAtColumn($name, $options, null); + } else { + $relation = $this->{$name}()->onlyTrashed()->getResults(); + if (!$relation) { + continue; + } + + if ($relation instanceof EloquentModel) { + $relation->restore(); + } elseif ($relation instanceof CollectionBase) { + $relation->each(function ($model) { + $model->restore(); + }); + } } } } diff --git a/tests/Database/ModelTest.php b/tests/Database/ModelTest.php index b2a4b8b2a..276384ed8 100644 --- a/tests/Database/ModelTest.php +++ b/tests/Database/ModelTest.php @@ -1,14 +1,460 @@ createTable(); + $this->createTables(); + $this->seedTables(); + } + + protected function createTables() + { + $this->getBuilder()->create('comments', function ($table) { + $table->increments('id'); + $table->string('title'); + $table->nullableMorphs('commentable'); + }); + + $this->getBuilder()->create('imageables', function ($table) { + $table->foreignId('image_id')->nullable(); + $table->nullableMorphs('imageable'); + }); + + $this->getBuilder()->create('images', function ($table) { + $table->increments('id'); + $table->string('name'); + }); + + $this->getBuilder()->create('phones', function ($table) { + $table->increments('id'); + $table->string('name'); + $table->foreignId('user_id')->nullable(); + }); + + $this->getBuilder()->create('posts', function ($table) { + $table->increments('id'); + $table->string('title'); + $table->foreignId('user_id')->nullable(); + }); + + $this->getBuilder()->create('role_user', function ($table) { + $table->increments('id'); + $table->foreignId('role_id')->nullable(); + $table->foreignId('user_id')->nullable(); + }); + + $this->getBuilder()->create('roles', function ($table) { + $table->increments('id'); + $table->string('name'); + }); + + $this->getBuilder()->create('tags', function ($table) { + $table->increments('id'); + $table->string('name'); + $table->nullableMorphs('taggable'); + }); + + $this->getBuilder()->create('users', function ($table) { + $table->increments('id'); + $table->string('name'); + $table->foreignId('website_id')->nullable(); + }); + + $this->getBuilder()->create('websites', function ($table) { + $table->increments('id'); + $table->string('url'); + }); + + $this->getBuilder()->create('test_model', function ($table) { + $table->increments('id'); + $table->string('name')->nullable(); + $table->text('data')->nullable(); + $table->text('description')->nullable(); + $table->text('meta')->nullable(); + $table->boolean('on_guard')->nullable(); + $table->timestamps(); + }); + } + + protected function seedTables() + { + $this->seeded['comments'][] = Comment::create(['title' => 'Comment1']); + $this->seeded['comments'][] = Comment::create(['title' => 'Comment2']); + + $this->seeded['images'][] = Image::create(['name' => 'Image1']); + $this->seeded['images'][] = Image::create(['name' => 'Image2']); + + $this->seeded['phones'][] = Phone::create(['name' => 'Phone1']); + $this->seeded['phones'][] = Phone::create(['name' => 'Phone2']); + + $this->seeded['roles'][] = Role::create(['name' => 'Role1']); + $this->seeded['roles'][] = Role::create(['name' => 'Role2']); + + $this->seeded['tags'][] = Tag::create(['name' => 'Tag1']); + $this->seeded['tags'][] = Tag::create(['name' => 'Tag2']); + + $this->seeded['posts'][] = Post::create(['title' => 'Post1']); + $this->seeded['posts'][0]->comments()->add($this->seeded['comments'][0]); + $this->seeded['posts'][0]->images()->attach($this->seeded['images'][0]); + $this->seeded['posts'][0]->tag()->add($this->seeded['tags'][0]); + + $this->seeded['posts'][] = Post::create(['title' => 'Post2']); + $this->seeded['posts'][1]->comments()->add($this->seeded['comments'][1]); + $this->seeded['posts'][1]->images()->attach($this->seeded['images'][1]); + $this->seeded['posts'][1]->tag()->add($this->seeded['tags'][1]); + + $this->seeded['users'][] = User::create(['name' => 'User1']); + $this->seeded['users'][0]->phone()->add($this->seeded['phones'][0]); + $this->seeded['users'][0]->posts()->add($this->seeded['posts'][0]); + $this->seeded['users'][0]->posts()->add($this->seeded['posts'][1]); + $this->seeded['users'][0]->roles()->attach($this->seeded['roles'][0]); + + $this->seeded['users'][] = User::create(['name' => 'User2']); + $this->seeded['users'][1]->phone()->add($this->seeded['phones'][1]); + $this->seeded['users'][1]->roles()->attach($this->seeded['roles'][0]); + $this->seeded['users'][1]->roles()->attach($this->seeded['roles'][1]); + + $this->seeded['websites'][] = Website::create(['url' => 'https://wintercms.com']); + $this->seeded['websites'][0]->users()->add($this->seeded['users'][0]); + + $this->seeded['websites'][] = Website::create(['url' => 'https://wintertricks.com']); + $this->seeded['websites'][1]->users()->add($this->seeded['users'][1]); + } + + // tests hasOneThrough & hasManyThrough + public function testDeleteWithThroughRelations() + { + $website = $this->seeded['websites'][0]; + $user = $this->seeded['users'][0]; + + $phoneCount = Phone::count(); + $postCount = Post::count(); + + $phoneRelationCount = $website->phone()->count(); + $postsRelationCount = $website->posts()->count(); + + $this->assertEquals($phoneRelationCount, $user->phone()->count()); + $this->assertEquals($postsRelationCount, $user->posts()->count()); + + $website->delete(); + + // verify nothing has been deleted + $this->assertEquals($phoneRelationCount, $user->phone()->count()); + $this->assertEquals($postsRelationCount, $user->posts()->count()); + + $this->assertEquals($phoneCount, Phone::count()); + $this->assertEquals($postCount, Post::count()); + } + + // tests hasMany + public function testDeleteWithHasManyRelation() + { + $website = $this->seeded['websites'][0]; + $user = $this->seeded['users'][0]; + + $websiteCount = Website::count(); + $userCount = User::count(); + + $website->delete(); + + // verify website has been deleted + $this->assertEquals($websiteCount - 1, Website::count()); + + // verify user still exists + $this->assertEquals($userCount, User::count()); + + // test with relation "delete" flag set to true + Website::extend(function ($model) { + $model->hasMany['users']['delete'] = true; + }); + + $website = Website::find($this->seeded['websites'][1]->id); + + $websiteCount = Website::count(); + $userCount = User::count(); + + $website->delete(); + + // verify website has been deleted + $this->assertEquals($websiteCount - 1, Website::count()); + + // verify user has been deleted + $this->assertEquals($userCount - 1, User::count()); + } + + // tests morphMany + public function testDeleteWithMorphManyRelation() + { + $post = $this->seeded['posts'][0]; + + $postCount = Post::count(); + $commentCount = Comment::count(); + + $post->delete(); + + // verify post has been deleted + $this->assertEquals($postCount - 1, Post::count()); + + // verify comment still exists + $this->assertEquals($commentCount, Comment::count()); + + // test with relation "delete" flag set to true + Post::extend(function ($model) { + $model->morphMany['comments']['delete'] = true; + }); + + $post = Post::find($this->seeded['posts'][1]->id); + + $postCount = Post::count(); + $commentCount = Comment::count(); + + $post->delete(); + + // verify post has been deleted + $this->assertEquals($postCount - 1, Post::count()); + + // verify comment has been deleted + $this->assertEquals($commentCount - 1, Comment::count()); + } + + // tests belongsToMany + public function testDeleteWithBelongsToManyRelation() + { + $user = $this->seeded['users'][0]; + + $userRolePivotCount = DB::table('role_user')->count(); + $userCount = User::count(); + $roleCount = Role::count(); + + $user->delete(); + + // verify user has been deleted + $this->assertEquals($userCount - 1, User::count()); + + // verify that pivot record has been removed + $this->assertEquals($userRolePivotCount - 1, DB::table('role_user')->count()); + + // verify both roles still exist + $this->assertEquals($roleCount, Role::count()); + + // test with relation "detach" flag set to false (default is true) + User::extend(function ($model) { + $model->belongsToMany['roles']['detach'] = false; + }); + + $user = User::find($this->seeded['users'][1]->id); + + $userRolePivotCount = DB::table('role_user')->count(); + $userCount = User::count(); + $roleCount = Role::count(); + + $user->delete(); + + // verify pivot record has NOT been removed + $this->assertEquals($userRolePivotCount, DB::table('role_user')->count()); + + // verify both roles still exist + $this->assertEquals($roleCount, Role::count()); + } + + // tests morphToMany + public function testDeleteWithMorphToManyRelation() + { + $post = $this->seeded['posts'][0]; + $image = $this->seeded['images'][0]; + + $imageablesPivotCount = DB::table('imageables')->count(); + $postCount = Post::count(); + $imageCount = Image::count(); + + $post->delete(); + + // verify that pivot record has been removed + $this->assertEquals($imageablesPivotCount - 1, DB::table('imageables')->count()); + + // verify post has been deleted + $this->assertEquals($postCount - 1, Post::count()); + + // verify image still exists + $this->assertEquals($imageCount, Image::count()); + + // test with relation "detach" flag set to false (default is true) + Post::extend(function ($model) { + $model->morphToMany['images']['detach'] = false; + }); + + $post = Post::find($this->seeded['posts'][1]->id); + + $imageablesPivotCount = DB::table('imageables')->count(); + $postCount = Post::count(); + $imageCount = Image::count(); + + $post->delete(); + + // verify that pivot record has NOT been removed + $this->assertEquals($imageablesPivotCount, DB::table('imageables')->count()); + + // verify post has been deleted + $this->assertEquals($postCount - 1, Post::count()); + + // verify image still exists + $this->assertEquals($imageCount, Image::count()); + } + + // tests morphedByMany + public function testDeleteWithMorphedByManyRelation() + { + $image = $this->seeded['images'][0]; + $post = $this->seeded['posts'][0]; + + $imageablesPivotCount = DB::table('imageables')->count(); + $imageCount = Image::count(); + $postCount = Post::count(); + + $image->delete(); + + // verify that pivot record has been removed + $this->assertEquals($imageablesPivotCount - 1, DB::table('imageables')->count()); + + // verify image has been deleted + $this->assertEquals($imageCount - 1, Image::count()); + + // verify post still exists + $this->assertEquals($postCount, Post::count()); + + // test with relation "detach" flag set to false (default is true) + Image::extend(function ($model) { + $model->morphedByMany['posts']['detach'] = false; + }); + + $image = Image::find($this->seeded['images'][1]->id); + + $imageablesPivotCount = DB::table('imageables')->count(); + $imageCount = Image::count(); + $postCount = Post::count(); + + $image->delete(); + + // verify that pivot record has NOT been removed + $this->assertEquals($imageablesPivotCount, DB::table('imageables')->count()); + + // verify image has been deleted + $this->assertEquals($imageCount - 1, Image::count()); + + // verify post still exists + $this->assertEquals($postCount, Post::count()); + } + + // tests hasOne + public function testDeleteWithHasOneRelation() + { + $user = $this->seeded['users'][0]; + + $userCount = User::count(); + $phoneCount = Phone::count(); + + $user->delete(); + + // verify user has been deleted + $this->assertEquals($userCount - 1, User::count()); + + // verify phone still exists + $this->assertEquals($phoneCount, Phone::count()); + + // test with relation "delete" flag set to true + User::extend(function ($model) { + $model->hasOne['phone']['delete'] = true; + }); + + $user = User::find($this->seeded['users'][1]->id); + + $userCount = User::count(); + $phoneCount = Phone::count(); + + $user->delete(); + + // verify user has been deleted + $this->assertEquals($userCount - 1, User::count()); + + // verify phone has been deleted + $this->assertEquals($phoneCount - 1, Phone::count()); + } + + // tests morphOne + public function testDeleteWithMorphOneRelation() + { + $post = $this->seeded['posts'][0]; + + $postCount = Post::count(); + $tagCount = Tag::count(); + + $post->delete(); + + // verify post has been deleted + $this->assertEquals($postCount - 1, Post::count()); + + // verify tag still exists + $this->assertEquals($tagCount, Tag::count()); + + // test with relation "delete" flag set to true + Post::extend(function ($model) { + $model->morphOne['tag']['delete'] = true; + }); + + $post = Post::find($this->seeded['posts'][1]->id); + + $postCount = Post::count(); + $tagCount = Tag::count(); + + $post->delete(); + + // verify post has been deleted + $this->assertEquals($postCount - 1, Post::count()); + + // verify tag has been deleted + $this->assertEquals($tagCount - 1, Tag::count()); + } + + // tests belongsTo + public function testDeleteWithBelongsToRelation() + { + $phone = $this->seeded['phones'][0]; + $phoneCount = Phone::count(); + $userCount = User::count(); + + $phone->delete(); + + // verify phone has been deleted + $this->assertEquals($phoneCount - 1, Phone::count()); + + // verify user has NOT been deleted + $this->assertEquals($userCount, User::count()); + } + + // tests morphTo + public function testDeleteWithMorphToRelation() + { + $comment = $this->seeded['comments'][0]; + $commentCount = Comment::count(); + $postCount = Post::count(); + + $comment->delete(); + + // verify comment has been deleted + $this->assertEquals($commentCount - 1, Comment::count()); + + // verify post has NOT been deleted + $this->assertEquals($postCount, Post::count()); } public function testAddCasts() @@ -188,19 +634,6 @@ public function testUpsert() $this->assertEquals('2', $modelMiddleRow->value); } - - protected function createTable() - { - $this->getBuilder()->create('test_model', function ($table) { - $table->increments('id'); - $table->string('name')->nullable(); - $table->text('data')->nullable(); - $table->text('description')->nullable(); - $table->text('meta')->nullable(); - $table->boolean('on_guard')->nullable(); - $table->timestamps(); - }); - } } class TestModelGuarded extends Model @@ -270,3 +703,89 @@ class TestModelMiddle extends Model public $table = 'test_model_middle'; } + +class BaseModel extends Model +{ + protected static $unguarded = true; + public $timestamps = false; +} + +class Comment extends BaseModel +{ + public $morphTo = [ + 'commentable' => [] + ]; +} + +class Image extends BaseModel +{ + public $morphedByMany = [ + 'posts' => [Post::class, 'name' => 'imageable'], + ]; +} + +class Phone extends BaseModel +{ + public $belongsTo = [ + 'user' => [User::class] + ]; +} + +class Post extends BaseModel +{ + public $belongsTo = [ + 'user' => [User::class] + ]; + public $morphOne = [ + 'tag' => [Tag::class, 'name' => 'taggable'] + ]; + public $morphMany = [ + 'comments' => [Comment::class, 'name' => 'commentable'] + ]; + public $morphToMany = [ + 'images' => [Image::class, 'name' => 'imageable'] + ]; +} + +class Role extends BaseModel +{ + public $belongsToMany = [ + 'users' => [User::class] + ]; +} + +class Tag extends BaseModel +{ + public $morphTo = [ + 'taggable' => [] + ]; +} + +class User extends BaseModel +{ + public $hasOne = [ + 'phone' => [Phone::class] + ]; + public $hasMany = [ + 'posts' => [Post::class] + ]; + public $belongsTo = [ + 'website' => [Website::class] + ]; + public $belongsToMany = [ + 'roles' => [Role::class] + ]; +} + +class Website extends BaseModel +{ + public $hasMany = [ + 'users' => [User::class] + ]; + public $hasOneThrough = [ + 'phone' => [Phone::class, 'through' => User::class] + ]; + public $hasManyThrough = [ + 'posts' => [Post::class, 'through' => User::class] + ]; +} diff --git a/tests/Database/Traits/SoftDeleteTest.php b/tests/Database/Traits/SoftDeleteTest.php new file mode 100644 index 000000000..258b8208b --- /dev/null +++ b/tests/Database/Traits/SoftDeleteTest.php @@ -0,0 +1,131 @@ +seeded = [ + 'posts' => [], + 'categories' => [] + ]; + + $this->createTables(); + $this->seedTables(); + } + + protected function createTables() + { + $this->getBuilder()->create('posts', function ($table) { + $table->increments('id'); + $table->string('title')->default(''); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + + $this->getBuilder()->create('categories', function ($table) { + $table->increments('id'); + $table->string('name'); + $table->timestamps(); + }); + + $this->getBuilder()->create('categories_posts', function ($table) { + $table->primary(['post_id', 'category_id']); + $table->unsignedInteger('post_id'); + $table->unsignedInteger('category_id'); + $table->timestamp('deleted_at')->nullable(); + }); + } + + protected function seedTables() + { + $this->seeded['posts'][] = Post::create([ + 'title' => 'First Post', + ]); + $this->seeded['posts'][] = Post::create([ + 'title' => 'Second Post', + ]); + + $this->seeded['categories'][] = Category::create([ + 'name' => 'Category 1' + ]); + $this->seeded['categories'][] = Category::create([ + 'name' => 'Category 2' + ]); + + $this->seeded['posts'][0]->categories()->attach($this->seeded['categories'][0]); + $this->seeded['posts'][0]->categories()->attach($this->seeded['categories'][1]); + + $this->seeded['posts'][1]->categories()->attach($this->seeded['categories'][0]); + $this->seeded['posts'][1]->categories()->attach($this->seeded['categories'][1]); + } + + public function testDeleteAndRestore() + { + $post = Post::first(); + $this->assertTrue($post->deleted_at === null); + $this->assertTrue($post->categories()->where('deleted_at', null)->count() === 2); + + $post->delete(); + + $post = Post::withTrashed()->first(); + $this->assertTrue($post->deleted_at != null); + $this->assertTrue($post->categories()->where('deleted_at', '!=', null)->count() === 2); + $post->restore(); + + $post = Post::first(); + $this->assertTrue($post->deleted_at === null); + $this->assertTrue($post->categories()->where('deleted_at', null)->count() === 2); + } +} + +class Post extends \Winter\Storm\Database\Model +{ + use \Winter\Storm\Database\Traits\SoftDelete; + + public $table = 'posts'; + + public $fillable = ['title']; + + protected $dates = [ + 'created_at', + 'updated_at', + 'deleted_at', + ]; + + public $belongsToMany = [ + 'categories' => [ + Category::class, + 'table' => 'categories_posts', + 'key' => 'post_id', + 'otherKey' => 'category_id', + 'softDelete' => true, + ], + ]; +} + +class Category extends \Winter\Storm\Database\Model +{ + public $table = 'categories'; + + public $fillable = ['name']; + + protected $dates = [ + 'created_at', + 'updated_at', + ]; + + public $belongsToMany = [ + 'posts' => [ + Post::class, + 'table' => 'categories_posts', + 'key' => 'category_id', + 'otherKey' => 'post_id', + ], + ]; +} From d1b95f7eef427f8c4bb255317548e3f130e23214 Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Thu, 19 Oct 2023 23:58:39 +0800 Subject: [PATCH 18/21] Remove deprecated "get_parent_class" calls in Extendable trait. (#153) Replaces #153. In PHP 8.3, [calling `get_class()` and `get_parent_class()` functions without arguments is deprecated](https://php.watch/versions/8.3/get_class-get_parent_class-parameterless-deprecated). References: - [PHP RFC: Deprecate functions with overloaded signatures](https://wiki.php.net/rfc/deprecate_functions_with_overloaded_signatures) - [PHP 8.3: get_class() and get_parent_class() function calls without arguments deprecated](https://php.watch/versions/8.3/get_class-get_parent_class-parameterless-deprecated) --- phpstan-baseline.neon | 20 ++----- src/Database/Model.php | 3 +- src/Extension/ExtendableTrait.php | 90 +++++++++++++++++++++++++--- tests/Database/UpdaterTest.php | 2 + tests/Foundation/ApplicationTest.php | 2 + tests/Html/BlockBuilderTest.php | 2 + tests/Support/EventFakeTest.php | 2 + 7 files changed, 95 insertions(+), 26 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 90ef336b2..1611ee26d 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -70,6 +70,11 @@ parameters: count: 1 path: src/Database/Model.php + - + message: "#^Static property Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:\\$dispatcher \\(Illuminate\\\\Contracts\\\\Events\\\\Dispatcher\\) in isset\\(\\) is not nullable\\.$#" + count: 1 + path: src/Database/Model.php + - message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Model\\:\\:errors\\(\\)\\.$#" count: 1 @@ -710,21 +715,6 @@ parameters: count: 1 path: src/Database/TreeCollection.php - - - message: "#^Winter\\\\Storm\\\\Extension\\\\Extendable\\:\\:extendableCall\\(\\) calls parent\\:\\:__call\\(\\) but Winter\\\\Storm\\\\Extension\\\\Extendable does not extend any class\\.$#" - count: 1 - path: src/Extension/Extendable.php - - - - message: "#^Winter\\\\Storm\\\\Extension\\\\Extendable\\:\\:extendableGet\\(\\) calls parent\\:\\:__get\\(\\) but Winter\\\\Storm\\\\Extension\\\\Extendable does not extend any class\\.$#" - count: 1 - path: src/Extension/Extendable.php - - - - message: "#^Winter\\\\Storm\\\\Extension\\\\Extendable\\:\\:extendableSet\\(\\) calls parent\\:\\:__set\\(\\) but Winter\\\\Storm\\\\Extension\\\\Extendable does not extend any class\\.$#" - count: 1 - path: src/Extension/Extendable.php - - message: "#^Parameter \\#2 \\$data \\(array\\) of method Winter\\\\Storm\\\\Mail\\\\Mailer\\:\\:queue\\(\\) should be compatible with parameter \\$queue \\(string\\|null\\) of method Illuminate\\\\Contracts\\\\Mail\\\\MailQueue\\:\\:queue\\(\\)$#" count: 1 diff --git a/src/Database/Model.php b/src/Database/Model.php index db68736c7..7d1772262 100644 --- a/src/Database/Model.php +++ b/src/Database/Model.php @@ -1,6 +1,6 @@ extensionGetParentClass(); + if ($parent !== false && $this->extensionMethodExists($parent, '__get')) { + return $this->extensionCallMethod($parent, '__get', [$name]); } return null; @@ -413,9 +414,9 @@ public function extendableSet($name, $value) /* * This targets trait usage in particular */ - $parent = get_parent_class(); - if ($parent !== false && method_exists($parent, '__set')) { - parent::__set($name, $value); + $parent = $this->extensionGetParentClass(); + if ($parent !== false && $this->extensionMethodExists($parent, '__set')) { + $this->extensionCallMethod($parent, '__set', [$name, $value]); } /* @@ -457,9 +458,9 @@ public function extendableCall($name, $params = null) } } - $parent = get_parent_class(); - if ($parent !== false && method_exists($parent, '__call')) { - return parent::__call($name, $params); + $parent = $this->extensionGetParentClass(); + if ($parent !== false && $this->extensionMethodExists($parent, '__call')) { + return $this->extensionCallMethod($parent, '__call', [$name, $params]); } throw new BadMethodCallException(sprintf( @@ -550,4 +551,75 @@ protected function extensionGetClassLoader(): ?ClassLoader return self::$extendableClassLoader = App::make(ClassLoader::class); } + + /** + * Gets the parent class using reflection. + * + * The parent class must either not be the `Extendable` class, or must not be using the `ExtendableTrait` trait, + * in order to prevent infinite loops. + * + * @return ReflectionClass|false + */ + protected function extensionGetParentClass(object $instance = null) + { + // Shortcut to prevent infinite loops if the class extends Extendable. + if ($this instanceof Extendable) { + return false; + } + + // Find if any parent uses the Extendable trait + if (!is_null($instance)) { + $reflector = $instance; + } else { + $reflector = new ReflectionClass($this); + } + $parent = $reflector->getParentClass(); + + // If there's no parent, stop here. + if ($parent === false) { + return false; + } + + while (!in_array(ExtendableTrait::class, $parent->getTraitNames())) { + $parent = $parent->getParentClass(); + if ($parent === false) { + break; + } + } + + // If no parent uses the Extendable trait, then return the parent class + if ($parent === false) { + return $reflector->getParentClass(); + } + + // Otherwise, we need to loop through until we find the parent class that doesn't use the Extendable trait + return $this->extensionGetParentClass($parent); + } + + /** + * Determines if the given class reflection contains the given method. + */ + protected function extensionMethodExists(ReflectionClass $class, string $methodName): bool + { + try { + $method = $class->getMethod($methodName); + + if (!$method->isPublic()) { + return false; + } + } catch (ReflectionException $e) { + return false; + } + + return true; + } + + /** + * Calls a method through reflection. + */ + protected function extensionCallMethod(ReflectionClass $class, string $method, array $params) + { + $method = $class->getMethod($method); + return $method->invokeArgs($this, $params); + } } diff --git a/tests/Database/UpdaterTest.php b/tests/Database/UpdaterTest.php index c8e0946e9..ef60d4573 100644 --- a/tests/Database/UpdaterTest.php +++ b/tests/Database/UpdaterTest.php @@ -4,6 +4,8 @@ class UpdaterTest extends TestCase { + protected Updater $updater; + public function setUp(): void { include_once __DIR__.'/../fixtures/database/SampleClass.php'; diff --git a/tests/Foundation/ApplicationTest.php b/tests/Foundation/ApplicationTest.php index 5897fde8a..f138de05a 100644 --- a/tests/Foundation/ApplicationTest.php +++ b/tests/Foundation/ApplicationTest.php @@ -5,6 +5,8 @@ class ApplicationTest extends TestCase { + protected string $basePath; + protected function setUp(): void { // Mock application diff --git a/tests/Html/BlockBuilderTest.php b/tests/Html/BlockBuilderTest.php index bcc0b28d4..a2e1fc6ab 100644 --- a/tests/Html/BlockBuilderTest.php +++ b/tests/Html/BlockBuilderTest.php @@ -4,6 +4,8 @@ class BlockBuilderTest extends TestCase { + protected BlockBuilder $Block; + public function setUp(): void { $this->Block = new BlockBuilder(); diff --git a/tests/Support/EventFakeTest.php b/tests/Support/EventFakeTest.php index f6654e235..5e74b6805 100644 --- a/tests/Support/EventFakeTest.php +++ b/tests/Support/EventFakeTest.php @@ -5,6 +5,8 @@ class EventFakeTest extends TestCase { + protected EventFake $faker; + public function setUp(): void { $this->faker = new EventFake(new Dispatcher); From 2941ef4a2c206f9be36182af5fd4d33b46f557bf Mon Sep 17 00:00:00 2001 From: Marc Jauvin Date: Thu, 19 Oct 2023 12:10:16 -0400 Subject: [PATCH 19/21] Restore previous tests order (#159) Co-authored-by: Luke Towers --- src/Database/Model.php | 9 +++++---- src/Database/Traits/SoftDelete.php | 5 +++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/Database/Model.php b/src/Database/Model.php index 7d1772262..403905965 100644 --- a/src/Database/Model.php +++ b/src/Database/Model.php @@ -1008,10 +1008,6 @@ protected function performDeleteOnRelations() * Hard 'delete' definition */ foreach ($relations as $name => $options) { - if (!$relation = $this->{$name}) { - continue; - } - if (in_array($type, ['belongsToMany', 'morphToMany', 'morphedByMany'])) { // we want to remove the pivot record, not the actual relation record if (Arr::get($options, 'detach', true)) { @@ -1025,6 +1021,11 @@ protected function performDeleteOnRelations() continue; } + // Attempt to load the related record(s) + if (!$relation = $this->{$name}) { + continue; + } + if ($relation instanceof EloquentModel) { $relation->forceDelete(); } elseif ($relation instanceof CollectionBase) { diff --git a/src/Database/Traits/SoftDelete.php b/src/Database/Traits/SoftDelete.php index fc913b8e8..82953eb2b 100644 --- a/src/Database/Traits/SoftDelete.php +++ b/src/Database/Traits/SoftDelete.php @@ -115,10 +115,11 @@ protected function performSoftDeleteOnRelations() $definitions = $this->getRelationDefinitions(); foreach ($definitions as $type => $relations) { foreach ($relations as $name => $options) { - if (!$relation = $this->{$name}) { + if (!array_get($options, 'softDelete', false)) { continue; } - if (!array_get($options, 'softDelete', false)) { + // Attempt to load the related record(s) + if (!$relation = $this->{$name}) { continue; } if (in_array($type, ['belongsToMany', 'morphToMany', 'morphedByMany'])) { From c779404d0ac742e890fef569b46d2db50af201ca Mon Sep 17 00:00:00 2001 From: Luke Towers Date: Wed, 29 Nov 2023 14:33:26 -0600 Subject: [PATCH 20/21] Make Svg::sanitize() a public method --- src/Support/Svg.php | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/Support/Svg.php b/src/Support/Svg.php index be9c232ce..32536f946 100644 --- a/src/Support/Svg.php +++ b/src/Support/Svg.php @@ -16,12 +16,9 @@ class Svg /** * Extracts and sanitizes SVG code from a given file. * - * @param string $path The path to the SVG file. - * @param boolean $minify Whether to minify the extracted SVG code. - * @return string * @throws ApplicationException If no file, or a malformed SVG, is found at the given path. */ - public static function extract($path, $minify = true) + public static function extract(string $path, bool $minify = true): string { if (!is_file($path)) { throw new ApplicationException(sprintf('No SVG file found at path "%s"', $path)); @@ -40,12 +37,8 @@ public static function extract($path, $minify = true) * Sanitizes SVG code. * * See https://github.com/darylldoyle/svg-sanitizer for usage of the underlying sanitization library. - * - * @param string $svg SVG code. - * @param boolean $minify Whether to minify the given SVG code. - * @return string */ - protected static function sanitize($svg, $minify = true) + public static function sanitize(string $svg, bool $minify = true): string { $sanitizer = new Sanitizer(); $sanitizer->removeRemoteReferences(true); From 1c8adb828228ec6ad61ec855d456e827e936e424 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mantas=20=C5=A0imk=C5=ABnas?= Date: Thu, 21 Dec 2023 07:16:29 +0200 Subject: [PATCH 21/21] Support Throwables in SystemExceptions (#160) Related: wintercms/winter#999 --- src/Exception/SystemException.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Exception/SystemException.php b/src/Exception/SystemException.php index 0a341a68f..349e28dbf 100644 --- a/src/Exception/SystemException.php +++ b/src/Exception/SystemException.php @@ -1,6 +1,6 @@