diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index c3a9cf686..1611ee26d 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 @@ -100,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 @@ -740,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/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/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) */ 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 */ 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/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/Model.php b/src/Database/Model.php index e1013b56b..403905965 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 + /** @phpstan-ignore-next-line */ + 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 @@ -176,11 +225,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); }); } } @@ -944,6 +997,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() @@ -954,31 +1008,30 @@ protected function performDeleteOnRelations() * Hard 'delete' definition */ foreach ($relations as $name => $options) { - if (!Arr::get($options, 'delete', false)) { - continue; - } - - if (!$relation = $this->{$name}) { + 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(); - }); - } - } + // Attempt to load the related record(s) + if (!$relation = $this->{$name}) { + continue; + } - /* - * Belongs-To-Many should clean up after itself always - */ - if ($type == 'belongsToMany') { - foreach ($relations as $name => $options) { - if (Arr::get($options, 'detach', true)) { - $this->{$name}()->detach(); + if ($relation instanceof EloquentModel) { + $relation->forceDelete(); + } elseif ($relation instanceof CollectionBase) { + $relation->each(function ($model) { + $model->forceDelete(); + }); } } } @@ -1044,6 +1097,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. @@ -1060,13 +1127,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); } 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/src/Database/Traits/SoftDelete.php b/src/Database/Traits/SoftDelete.php index 1b7243514..82953eb2b 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); }); } @@ -114,18 +118,25 @@ protected function performSoftDeleteOnRelations() if (!array_get($options, 'softDelete', false)) { continue; } - + // Attempt to load the related record(s) if (!$relation = $this->{$name}) { continue; } - - if ($relation instanceof EloquentModel) { - $relation->delete(); - } - 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(); + }); + } } } } @@ -173,6 +184,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. * @@ -187,18 +211,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/src/Database/Traits/Validation.php b/src/Database/Traits/Validation.php index a4f68504b..ee3ca8ba6 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) { /* @@ -111,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; } /** @@ -174,19 +237,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 +245,6 @@ public function validate($rules = null, $customMessages = null, $attributeNames return false; } - if ($this->methodExists('beforeValidate')) { - $this->beforeValidate(); - } - /* * Perform validation */ @@ -323,23 +370,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/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/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 @@ 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/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; } diff --git a/src/Foundation/Application.php b/src/Foundation/Application.php index ea1028be4..a550c4745 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. * @@ -362,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 diff --git a/src/Foundation/Console/EventListCommand.php b/src/Foundation/Console/EventListCommand.php new file mode 100644 index 000000000..b972866af --- /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; + } +} 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, 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); }); } } 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; } 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); 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'; +} diff --git a/tests/Database/ModelTest.php b/tests/Database/ModelTest.php index f704f7e16..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() @@ -132,17 +578,61 @@ public function testHiddenAttributes() $this->assertArrayNotHasKey('description', $model->toArray()); } - protected function createTable() + public function testUpsert() { - $this->getBuilder()->create('test_model', function ($table) { + $this->getBuilder()->create('test_model2', 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(); }); + + $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); } } @@ -199,3 +689,103 @@ 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'; +} + +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', + ], + ]; +} 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/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); 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);