diff --git a/src/app/Console/Command.php b/src/app/Console/Command.php index 18cf47ae..6186e14d 100644 --- a/src/app/Console/Command.php +++ b/src/app/Console/Command.php @@ -1,264 +1,269 @@ output->createProgressBar($count); $bar->setFormat( '%current:7s%/%max:7s% [%bar%] %percent:3s%% %elapsed:7s%/%estimated:-7s% %message% ' ); if ($message) { $bar->setMessage("{$message}..."); } $bar->start(); return $bar; } /** * Find the domain. * * @param string $domain Domain ID or namespace * @param bool $withDeleted Include deleted * * @return \App\Domain|null */ public function getDomain($domain, $withDeleted = false) { return $this->getObject(\App\Domain::class, $domain, 'namespace', $withDeleted); } /** * Find a group. * * @param string $group Group ID or email * @param bool $withDeleted Include deleted * * @return \App\Group|null */ public function getGroup($group, $withDeleted = false) { return $this->getObject(\App\Group::class, $group, 'email', $withDeleted); } /** * Find an object. * * @param string $objectClass The name of the class * @param string $objectIdOrTitle The name of a database field to match. * @param string|null $objectTitle An additional database field to match. * @param bool $withDeleted Act as if --with-deleted was used * * @return mixed */ public function getObject($objectClass, $objectIdOrTitle, $objectTitle = null, $withDeleted = false) { if (!$withDeleted) { // @phpstan-ignore-next-line $withDeleted = $this->hasOption('with-deleted') && $this->option('with-deleted'); } $object = $this->getObjectModel($objectClass, $withDeleted)->find($objectIdOrTitle); if (!$object && !empty($objectTitle)) { $object = $this->getObjectModel($objectClass, $withDeleted) ->where($objectTitle, $objectIdOrTitle)->first(); } return $object; } /** * Returns a preconfigured Model object for a specified class. * * @param string $objectClass The name of the class * @param bool $withDeleted Include withTrashed() query * * @return mixed */ protected function getObjectModel($objectClass, $withDeleted = false) { if ($withDeleted) { $model = $objectClass::withTrashed(); } else { $model = new $objectClass(); } if ($this->commandPrefix == 'scalpel') { return $model; } $modelsWithOwner = [ \App\Wallet::class, ]; $tenantId = \config('app.tenant_id'); // Add tenant filter if (in_array(\App\Traits\BelongsToTenantTrait::class, class_uses($objectClass))) { $model = $model->withEnvTenantContext(); } elseif (in_array($objectClass, $modelsWithOwner)) { $model = $model->whereExists(function ($query) use ($tenantId) { $query->select(DB::raw(1)) ->from('users') ->whereRaw('wallets.user_id = users.id') ->whereRaw('users.tenant_id ' . ($tenantId ? "= $tenantId" : 'is null')); }); } return $model; } /** * Find a resource. * * @param string $resource Resource ID or email * @param bool $withDeleted Include deleted * * @return \App\Resource|null */ public function getResource($resource, $withDeleted = false) { return $this->getObject(\App\Resource::class, $resource, 'email', $withDeleted); } /** * Find a shared folder. * * @param string $folder Folder ID or email * @param bool $withDeleted Include deleted * * @return \App\SharedFolder|null */ public function getSharedFolder($folder, $withDeleted = false) { return $this->getObject(\App\SharedFolder::class, $folder, 'email', $withDeleted); } /** * Find the user. * * @param string $user User ID or email * @param bool $withDeleted Include deleted * * @return \App\User|null */ public function getUser($user, $withDeleted = false) { return $this->getObject(\App\User::class, $user, 'email', $withDeleted); } /** * Find the wallet. * * @param string $wallet Wallet ID * * @return \App\Wallet|null */ public function getWallet($wallet) { return $this->getObject(\App\Wallet::class, $wallet, null); } + /** + * Execute the console command. + * + * @return mixed + */ public function handle() { if ($this->dangerous) { $this->warn( "This command is a dangerous scalpel command with potentially significant unintended consequences" ); $confirmation = $this->confirm("Are you sure you understand what's about to happen?"); if (!$confirmation) { $this->info("Better safe than sorry."); return false; } $this->info("VĂ¡monos!"); } return true; } /** * Return a string for output, with any additional attributes specified as well. * * @param mixed $entry An object * * @return string */ protected function toString($entry) { /** * Haven't figured out yet, how to test if this command implements an option for additional * attributes. if (!in_array('attr', $this->options())) { return $entry->{$entry->getKeyName()}; } */ $str = [ $entry->{$entry->getKeyName()} ]; // @phpstan-ignore-next-line foreach ($this->option('attr') as $attr) { if ($attr == $entry->getKeyName()) { $this->warn("Specifying {$attr} is not useful."); continue; } if (!array_key_exists($attr, $entry->toArray())) { $this->error("Attribute {$attr} isn't available"); continue; } if (is_numeric($entry->{$attr})) { $str[] = $entry->{$attr}; } else { $str[] = !empty($entry->{$attr}) ? $entry->{$attr} : "null"; } } return implode(" ", $str); } } diff --git a/src/app/Console/Commands/Scalpel/TenantSetting/CreateCommand.php b/src/app/Console/Commands/Scalpel/TenantSetting/CreateCommand.php index 44b590c0..daa73d16 100644 --- a/src/app/Console/Commands/Scalpel/TenantSetting/CreateCommand.php +++ b/src/app/Console/Commands/Scalpel/TenantSetting/CreateCommand.php @@ -1,13 +1,15 @@ geese). * * @var string */ protected $objectNamePlural; /** * A column name other than the primary key can be used to identify an object, such as 'email' for users, * 'namespace' for domains, and 'title' for SKUs. * * @var string */ protected $objectTitle; /** * Placeholder for column name attributes for objects, from which command-line switches and attribute names can be * generated. * * @var array */ protected $properties; } diff --git a/src/app/Console/ObjectCreateCommand.php b/src/app/Console/ObjectCreateCommand.php index c38e5d84..05d9440a 100644 --- a/src/app/Console/ObjectCreateCommand.php +++ b/src/app/Console/ObjectCreateCommand.php @@ -1,64 +1,58 @@ description = "Create a {$this->objectName}"; $this->signature = sprintf( "%s%s:create", $this->commandPrefix ? $this->commandPrefix . ":" : "", $this->objectName ); $class = new $this->objectClass(); foreach ($class->getFillable() as $fillable) { $this->signature .= " {--{$fillable}=}"; } parent::__construct(); } - public function getProperties() + protected function getProperties() { if (!empty($this->properties)) { return $this->properties; } $class = new $this->objectClass(); $this->properties = []; foreach ($class->getFillable() as $fillable) { $this->properties[$fillable] = $this->option($fillable); } return $this->properties; } /** * Execute the console command. - * - * @return mixed */ public function handle() { - $this->getProperties(); - - $class = new $this->objectClass(); - - $object = $this->objectClass::create($this->properties); + $object = $this->objectClass::create($this->getProperties()); if ($object) { - $this->info($object->{$class->getKeyName()}); + $this->info($object->{$object->getKeyName()}); } else { $this->error("Object could not be created."); } } } diff --git a/src/app/Console/ObjectDeleteCommand.php b/src/app/Console/ObjectDeleteCommand.php index f9e0d2d5..efcbb3f1 100644 --- a/src/app/Console/ObjectDeleteCommand.php +++ b/src/app/Console/ObjectDeleteCommand.php @@ -1,105 +1,67 @@ description = "Delete a {$this->objectName}"; $this->signature = sprintf( "%s%s:delete {%s}", $this->commandPrefix ? $this->commandPrefix . ":" : "", $this->objectName, $this->objectName ); - $class = new $this->objectClass(); - - try { - foreach (Schema::getColumnListing($class->getTable()) as $column) { - if ($column == "id") { - continue; - } - - $this->signature .= " {--{$column}=}"; - } - } catch (\Exception $e) { - \Log::error("Could not extract options: {$e->getMessage()}"); - } - $classes = class_uses_recursive($this->objectClass); if (in_array(SoftDeletes::class, $classes)) { $this->signature .= " {--with-deleted : Consider deleted {$this->objectName}s}"; } parent::__construct(); } - public function getProperties() - { - if (!empty($this->properties)) { - return $this->properties; - } - - $class = new $this->objectClass(); - - $this->properties = []; - - foreach (Schema::getColumnListing($class->getTable()) as $column) { - if ($column == "id") { - continue; - } - - if (($value = $this->option($column)) !== null) { - $this->properties[$column] = $value; - } - } - - return $this->properties; - } - /** * Execute the console command. * * @return mixed */ public function handle() { $result = parent::handle(); if (!$result) { return 1; } $argument = $this->argument($this->objectName); $object = $this->getObject($this->objectClass, $argument, $this->objectTitle); if (!$object) { $this->error("No such {$this->objectName} {$argument}"); return 1; } if ($this->commandPrefix == 'scalpel') { if ($object->deleted_at) { $object->forceDeleteQuietly(); } else { $object->deleteQuietly(); } } else { if ($object->deleted_at) { $object->forceDelete(); } else { $object->delete(); } } } } diff --git a/src/app/Console/ObjectUpdateCommand.php b/src/app/Console/ObjectUpdateCommand.php index 17f12fef..250067be 100644 --- a/src/app/Console/ObjectUpdateCommand.php +++ b/src/app/Console/ObjectUpdateCommand.php @@ -1,101 +1,133 @@ description = "Update a {$this->objectName}"; $this->signature = sprintf( "%s%s:update {%s}", $this->commandPrefix ? $this->commandPrefix . ":" : "", $this->objectName, $this->objectName ); - $class = new $this->objectClass(); + // This constructor is called for every ObjectUpdateCommand command, + // no matter which command is being executed. We should not use database + // access from here. And it should be as fast as possible. - try { - foreach (Schema::getColumnListing($class->getTable()) as $column) { - if ($column == "id") { - continue; - } + $class = new $this->objectClass(); - $this->signature .= " {--{$column}=}"; + foreach ($this->getClassProperties() as $property) { + if ($property == 'id') { + continue; } - } catch (\Exception $e) { - \Log::error("Could not extract options: {$e->getMessage()}"); - } - $classes = class_uses_recursive($this->objectClass); + $this->signature .= " {--{$property}=}"; + } - if (in_array(SoftDeletes::class, $classes)) { + if (method_exists($class, 'restore')) { $this->signature .= " {--with-deleted : Include deleted {$this->objectName}s}"; } parent::__construct(); } + /** + * Get all properties (sql table columns) of the model class + */ + protected function getClassProperties(): array + { + // We are not using table information schema, because it makes + // all artisan commands slow. We depend on the @property definitions + // in the class documentation comment. + + $reflector = new \ReflectionClass($this->objectClass); + $list = []; + + if (preg_match_all('/@property\s+([^$\s]+)\s+\$([a-z_]+)/', $reflector->getDocComment(), $matches)) { + foreach ($matches[1] as $key => $type) { + $type = preg_replace('/[\?]/', '', $type); + if (preg_match('/^(int|string|float|bool|\\Carbon\\Carbon)$/', $type)) { + $list[] = $matches[2][$key]; + } + } + } + + // Add created_at, updated_at, deleted_at where applicable + if ($this->commandPrefix == 'scalpel') { + $class = new $this->objectClass(); + + if ($class->timestamps && !in_array('created_at', $list)) { + $list[] = 'created_at'; + } + if ($class->timestamps && !in_array('updated_at', $list)) { + $list[] = 'updated_at'; + } + if (method_exists($class, 'restore') && !in_array('deleted_at', $list)) { + $list[] = 'deleted_at'; + } + } + + return $list; + } + public function getProperties() { if (!empty($this->properties)) { return $this->properties; } $class = new $this->objectClass(); $this->properties = []; - foreach (Schema::getColumnListing($class->getTable()) as $column) { - if ($column == "id") { + foreach ($this->getClassProperties() as $property) { + if ($property == 'id') { continue; } - if (($value = $this->option($column)) !== null) { - $this->properties[$column] = $value; + if (($value = $this->option($property)) !== null) { + $this->properties[$property] = $value; } } return $this->properties; } /** * Execute the console command. * * @return mixed */ public function handle() { $argument = $this->argument($this->objectName); $object = $this->getObject($this->objectClass, $argument, $this->objectTitle); if (!$object) { $this->error("No such {$this->objectName} {$argument}"); return 1; } foreach ($this->getProperties() as $property => $value) { - if ($property == "deleted_at" && $value == "null") { + if ($property == 'deleted_at' && $value === 'null') { $value = null; } $object->{$property} = $value; } - $object->timestamps = false; - if ($this->commandPrefix == 'scalpel') { $object->saveQuietly(); } else { $object->save(); } } } diff --git a/src/tests/Feature/Console/Scalpel/Domain/UpdateCommandTest.php b/src/tests/Feature/Console/Scalpel/Domain/UpdateCommandTest.php new file mode 100644 index 00000000..f9d34ea7 --- /dev/null +++ b/src/tests/Feature/Console/Scalpel/Domain/UpdateCommandTest.php @@ -0,0 +1,70 @@ +deleteTestDomain('domain-delete.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestDomain('domain-delete.com'); + $this->deleteTestDomain('domain-delete-mod.com'); + + parent::tearDown(); + } + + /** + * Test the command execution + */ + public function testHandle(): void + { + // Test unknown domain + $this->artisan("scalpel:domain:update unknown") + ->assertExitCode(1) + ->expectsOutput("No such domain unknown"); + + $domain = $this->getTestDomain('domain-delete.com', [ + 'status' => Domain::STATUS_NEW, + 'type' => Domain::TYPE_HOSTED, + ]); + + // Test successful update + $this->artisan("scalpel:domain:update {$domain->id}" + . " --namespace=domain-delete-mod.com --type=" . Domain::TYPE_PUBLIC) + ->assertExitCode(0); + + $domain->refresh(); + + $this->assertSame('domain-delete-mod.com', $domain->namespace); + $this->assertSame(Domain::TYPE_PUBLIC, $domain->type); + + // Test --help argument + $code = \Artisan::call("scalpel:domain:update --help"); + $output = trim(\Artisan::output()); + + $this->assertStringContainsString('--with-deleted', $output); + $this->assertStringContainsString('--namespace[=NAMESPACE]', $output); + $this->assertStringContainsString('--type[=TYPE]', $output); + $this->assertStringContainsString('--status[=STATUS]', $output); + $this->assertStringContainsString('--tenant_id[=TENANT_ID]', $output); + $this->assertStringContainsString('--created_at[=CREATED_AT]', $output); + $this->assertStringContainsString('--updated_at[=UPDATED_AT]', $output); + $this->assertStringContainsString('--deleted_at[=DELETED_AT]', $output); + $this->assertStringNotContainsString('--id', $output); + } +}