Repository: laravel/framework (php)

Issue:
#40119 broke how `dbal` array in `config/database.php` is handled.
<!-- DO NOT THROW THIS AWAY -->
<!-- Fill out the FULL versions with patch versions -->

- Laravel Version: 8.78.1
- PHP Version: 8.1.0
- Database Driver & Version: MySQL 8

### Description:

#40119 introduced an improvement for handling custom Doctrine types. But for some people it broke the test suites. (https://github.com/laravel/framework/pull/40119#issuecomment-1005854737 and https://github.com/laravel/framework/pull/40119#issuecomment-1006906061)

For sequential test runs issue happens when there is only the `dbal` array in `config/database.php` And the issue is gone when adding `DB::registerDoctrineType call` to `AppServiceProvider`. Which is not a good solution because it causes the application to make DB connection while the app is booting.

For parallel test runs issue happens in both cases.

### Steps To Reproduce:

[This](https://github.com/canvural/doctrine-type-bug) repo shows the bug. 


Pull request description:
[8.x] Fix Doctrine type mappings creating too many connections
**TLDR:** this PR closes #40297 by only registering custom Doctrine type mappings on a Doctrine connection after that connection is created (as opposed to creating one to register them).

### Context

My and @TomHAnderson's original PR, #40119, created an issue with tests where way too many database connections would be created. We were manually creating a database connection to register the custom type mappings, which caused database connections to be created every time the app boots, and dozens of unnecessary connections to be created when tests are run in parallel.

I'm still not entirely sure why all those connections stick around, I tried manually closing/destroying them through Doctrine and that didn't seem to work.

Patch:
diff --git a/src/Illuminate/Console/Scheduling/Schedule.php b/src/Illuminate/Console/Scheduling/Schedule.php
index 2e8ae51ce4..ee5412b7b4 100644
--- a/src/Illuminate/Console/Scheduling/Schedule.php
+++ b/src/Illuminate/Console/Scheduling/Schedule.php
@@ -120,7 +120,11 @@ public function call($callback, array $parameters = [])
     public function command($command, array $parameters = [])
     {
         if (class_exists($command)) {
-            $command = Container::getInstance()->make($command)->getName();
+            $command = Container::getInstance()->make($command);
+
+            return $this->exec(
+                Application::formatCommandString($command->getName()), $parameters,
+            )->description($command->getDescription());
         }
 
         return $this->exec(
diff --git a/src/Illuminate/Console/Scheduling/ScheduleListCommand.php b/src/Illuminate/Console/Scheduling/ScheduleListCommand.php
index 3b9780f13b..b1b0bdde59 100644
--- a/src/Illuminate/Console/Scheduling/ScheduleListCommand.php
+++ b/src/Illuminate/Console/Scheduling/ScheduleListCommand.php
@@ -3,6 +3,7 @@
 namespace Illuminate\Console\Scheduling;
 
 use Cron\CronExpression;
+use DateTimeZone;
 use Illuminate\Console\Command;
 use Illuminate\Support\Carbon;
 
@@ -39,7 +40,7 @@ public function handle(Schedule $schedule)
                 $event->description,
                 (new CronExpression($event->expression))
                             ->getNextRunDate(Carbon::now()->setTimezone($event->timezone))
-                            ->setTimezone($this->option('timezone', config('app.timezone')))
+                            ->setTimezone(new DateTimeZone($this->option('timezone') ?? config('app.timezone')))
                             ->format('Y-m-d H:i:s P'),
             ];
         }
diff --git a/src/Illuminate/Database/Connection.php b/src/Illuminate/Database/Connection.php
index e35868eaf3..df1a020265 100755
--- a/src/Illuminate/Database/Connection.php
+++ b/src/Illuminate/Database/Connection.php
@@ -177,6 +177,13 @@ class Connection implements ConnectionInterface
      */
     protected $doctrineConnection;
 
+    /**
+     * Type mappings that should be registered with new Doctrine connections.
+     *
+     * @var array
+     */
+    protected $doctrineTypeMappings = [];
+
     /**
      * The connection resolvers.
      *
@@ -1007,6 +1014,12 @@ public function getDoctrineConnection()
                 'driver' => method_exists($driver, 'getName') ? $driver->getName() : null,
                 'serverVersion' => $this->getConfig('server_version'),
             ]), $driver);
+
+            foreach ($this->doctrineTypeMappings as $name => $type) {
+                $this->doctrineConnection
+                    ->getDatabasePlatform()
+                    ->registerDoctrineTypeMapping($type, $name);
+            }
         }
 
         return $this->doctrineConnection;
@@ -1035,9 +1048,7 @@ public function registerDoctrineType(string $class, string $name, string $type):
             Type::addType($name, $class);
         }
 
-        $this->getDoctrineSchemaManager()
-            ->getDatabasePlatform()
-            ->registerDoctrineTypeMapping($type, $name);
+        $this->doctrineTypeMappings[$name] = $type;
     }
 
     /**
diff --git a/src/Illuminate/Database/DatabaseManager.php b/src/Illuminate/Database/DatabaseManager.php
index f53523b5b6..cb823bfa3d 100755
--- a/src/Illuminate/Database/DatabaseManager.php
+++ b/src/Illuminate/Database/DatabaseManager.php
@@ -2,12 +2,14 @@
 
 namespace Illuminate\Database;
 
+use Doctrine\DBAL\Types\Type;
 use Illuminate\Database\Connectors\ConnectionFactory;
 use Illuminate\Support\Arr;
 use Illuminate\Support\ConfigurationUrlParser;
 use Illuminate\Support\Str;
 use InvalidArgumentException;
 use PDO;
+use RuntimeException;
 
 /**
  * @mixin \Illuminate\Database\Connection
@@ -49,6 +51,13 @@ class DatabaseManager implements ConnectionResolverInterface
      */
     protected $reconnector;
 
+    /**
+     * The custom Doctrine column types.
+     *
+     * @var array
+     */
+    protected $doctrineTypes = [];
+
     /**
      * Create a new database manager instance.
      *
@@ -207,7 +216,7 @@ protected function setPdoForType(Connection $connection, $type = null)
     }
 
     /**
-     * Register custom Doctrine types from the configuration with the connection.
+     * Register custom Doctrine types with the connection.
      *
      * @param  \Illuminate\Database\Connection  $connection
      * @return void
@@ -215,8 +224,38 @@ protected function setPdoForType(Connection $connection, $type = null)
     protected function registerConfiguredDoctrineTypes(Connection $connection): void
     {
         foreach ($this->app['config']->get('database.dbal.types', []) as $name => $class) {
-            $connection->registerDoctrineType($class, $name, $name);
+            $this->registerDoctrineType($class, $name, $name);
+        }
+
+        foreach ($this->doctrineTypes as $name => [$type, $class]) {
+            $connection->registerDoctrineType($class, $name, $type);
+        }
+    }
+
+    /**
+     * Register a custom Doctrine type.
+     *
+     * @param  string  $class
+     * @param  string  $name
+     * @param  string  $type
+     * @return void
+     *
+     * @throws \Doctrine\DBAL\DBALException
+     * @throws \RuntimeException
+     */
+    public function registerDoctrineType(string $class, string $name, string $type): void
+    {
+        if (! class_exists('Doctrine\DBAL\Connection')) {
+            throw new RuntimeException(
+                'Registering a custom Doctrine type requires Doctrine DBAL (doctrine/dbal).'
+            );
         }
+
+        if (! Type::hasType($name)) {
+            Type::addType($name, $class);
+        }
+
+        $this->doctrineTypes[$name] = [$type, $class];
     }
 
     /**
diff --git a/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php b/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php
index dedd35eb62..36c589d4e5 100644
--- a/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php
+++ b/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php
@@ -1637,7 +1637,13 @@ protected function mergeAttributesFromClassCasts()
     protected function mergeAttributesFromAttributeCasts()
     {
         foreach ($this->attributeCastCache as $key => $value) {
-            $callback = $this->{Str::camel($key)}()->set ?: function ($value) use ($key) {
+            $attribute = $this->{Str::camel($key)}();
+
+            if ($attribute->get && ! $attribute->set) {
+                continue;
+            }
+
+            $callback = $attribute->set ?: function ($value) use ($key) {
                 $this->attributes[$key] = $value;
             };
 
diff --git a/src/Illuminate/Log/LogManager.php b/src/Illuminate/Log/LogManager.php
index 7bac654a60..7bcf36299f 100644
--- a/src/Illuminate/Log/LogManager.php
+++ b/src/Illuminate/Log/LogManager.php
@@ -7,6 +7,7 @@
 use InvalidArgumentException;
 use Monolog\Formatter\LineFormatter;
 use Monolog\Handler\ErrorLogHandler;
+use Monolog\Handler\FingersCrossedHandler;
 use Monolog\Handler\FormattableHandlerInterface;
 use Monolog\Handler\HandlerInterface;
 use Monolog\Handler\RotatingFileHandler;
@@ -416,6 +417,10 @@ protected function prepareHandlers(array $handlers)
      */
     protected function prepareHandler(HandlerInterface $handler, array $config = [])
     {
+        if (isset($config['action_level'])) {
+            $handler = new FingersCrossedHandler($handler, $this->actionLevel($config));
+        }
+
         if (Monolog::API !== 1 && (Monolog::API !== 2 || ! $handler instanceof FormattableHandlerInterface)) {
             return $handler;
         }
diff --git a/src/Illuminate/Log/ParsesLogConfiguration.php b/src/Illuminate/Log/ParsesLogConfiguration.php
index f40cf6b504..fd0d5ed570 100644
--- a/src/Illuminate/Log/ParsesLogConfiguration.php
+++ b/src/Illuminate/Log/ParsesLogConfiguration.php
@@ -49,6 +49,23 @@ protected function level(array $config)
         throw new InvalidArgumentException('Invalid log level.');
     }
 
+    /**
+     * Parse the action level from the given configuration.
+     *
+     * @param  array  $config
+     * @return int
+     */
+    protected function actionLevel(array $config)
+    {
+        $level = $config['action_level'] ?? 'debug';
+
+        if (isset($this->levels[$level])) {
+            return $this->levels[$level];
+        }
+
+        throw new InvalidArgumentException('Invalid log action level.');
+    }
+
     /**
      * Extract the log channel from the given configuration.
      *


Test patch:
diff --git a/tests/Console/ConsoleEventSchedulerTest.php b/tests/Console/ConsoleEventSchedulerTest.php
index 66a6845f6a..4e8e5e2ee7 100644
--- a/tests/Console/ConsoleEventSchedulerTest.php
+++ b/tests/Console/ConsoleEventSchedulerTest.php
@@ -120,6 +120,21 @@ public function testCreateNewArtisanCommandUsingCommandClass()
         $this->assertEquals($binary.' '.$artisan.' foo:bar --force', $events[0]->command);
     }
 
+    public function testItUsesCommandDescriptionAsEventDescription()
+    {
+        $schedule = $this->schedule;
+        $event = $schedule->command(ConsoleCommandStub::class);
+        $this->assertEquals('This is a description about the command', $event->description);
+    }
+
+    public function testItShouldBePossibleToOverwriteTheDescription()
+    {
+        $schedule = $this->schedule;
+        $event = $schedule->command(ConsoleCommandStub::class)
+            ->description('This is an alternative description');
+        $this->assertEquals('This is an alternative description', $event->description);
+    }
+
     public function testCallCreatesNewJobWithTimezone()
     {
         $schedule = new Schedule('UTC');
@@ -148,6 +163,8 @@ class ConsoleCommandStub extends Command
 {
     protected $signature = 'foo:bar';
 
+    protected $description = 'This is a description about the command';
+
     protected $foo;
 
     public function __construct(FooClassStub $foo)
diff --git a/tests/Integration/Database/DatabaseEloquentModelAttributeCastingTest.php b/tests/Integration/Database/DatabaseEloquentModelAttributeCastingTest.php
index fc2e8df34c..6235aeb8da 100644
--- a/tests/Integration/Database/DatabaseEloquentModelAttributeCastingTest.php
+++ b/tests/Integration/Database/DatabaseEloquentModelAttributeCastingTest.php
@@ -177,6 +177,17 @@ public function testSettingRawAttributesClearsTheCastCache()
 
         $this->assertSame('117 Spencer St.', $model->address->lineOne);
     }
+
+    public function testCastsThatOnlyHaveGetterDoNotPeristAnythingToModelOnSave()
+    {
+        $model = new TestEloquentModelWithAttributeCast;
+
+        $model->virtual;
+
+        $model->getAttributes();
+
+        $this->assertTrue(empty($model->getDirty()));
+    }
 }
 
 class TestEloquentModelWithAttributeCast extends Model
@@ -253,6 +264,15 @@ public function password(): Attribute
             return hash('sha256', $value);
         });
     }
+
+    public function virtual(): Attribute
+    {
+        return new Attribute(
+            function () {
+                return collect();
+            }
+        );
+    }
 }
 
 class AttributeCastAddress
diff --git a/tests/Log/LogManagerTest.php b/tests/Log/LogManagerTest.php
index b460d6ec51..b1d6c198ff 100755
--- a/tests/Log/LogManagerTest.php
+++ b/tests/Log/LogManagerTest.php
@@ -7,6 +7,7 @@
 use Monolog\Formatter\HtmlFormatter;
 use Monolog\Formatter\LineFormatter;
 use Monolog\Formatter\NormalizerFormatter;
+use Monolog\Handler\FingersCrossedHandler;
 use Monolog\Handler\LogEntriesHandler;
 use Monolog\Handler\NewRelicHandler;
 use Monolog\Handler\NullHandler;
@@ -432,4 +433,52 @@ public function __invoke()
 
         $this->assertSame(storage_path('logs/custom.log'), $url->getValue($handler));
     }
+
+    public function testWrappingHandlerInFingersCrossedWhenActionLevelIsUsed()
+    {
+        $config = $this->app['config'];
+
+        $config->set('logging.channels.fingerscrossed', [
+            'driver' => 'monolog',
+            'handler' => StreamHandler::class,
+            'level' => 'debug',
+            'action_level' => 'critical',
+            'with' => [
+                'stream' => 'php://stderr',
+                'bubble' => false,
+            ],
+        ]);
+
+        $manager = new LogManager($this->app);
+
+        // create logger with handler specified from configuration
+        $logger = $manager->channel('fingerscrossed');
+        $handlers = $logger->getLogger()->getHandlers();
+
+        $this->assertInstanceOf(Logger::class, $logger);
+        $this->assertCount(1, $handlers);
+
+        $expectedFingersCrossedHandler = $handlers[0];
+        $this->assertInstanceOf(FingersCrossedHandler::class, $expectedFingersCrossedHandler, );
+
+        $activationStrategyProp = new ReflectionProperty(get_class($expectedFingersCrossedHandler), 'activationStrategy');
+        $activationStrategyProp->setAccessible(true);
+        $activationStrategyValue = $activationStrategyProp->getValue($expectedFingersCrossedHandler);
+
+        $actionLevelProp = new ReflectionProperty(get_class($activationStrategyValue), 'actionLevel');
+        $actionLevelProp->setAccessible(true);
+        $actionLevelValue = $actionLevelProp->getValue($activationStrategyValue);
+
+        $this->assertEquals(Monolog::CRITICAL, $actionLevelValue);
+
+        if (method_exists($expectedFingersCrossedHandler, 'getHandler')) {
+            $expectedStreamHandler = $expectedFingersCrossedHandler->getHandler();
+        } else {
+            $handlerProp = new ReflectionProperty(get_class($expectedFingersCrossedHandler), 'handler');
+            $handlerProp->setAccessible(true);
+            $expectedStreamHandler = $handlerProp->getValue($expectedFingersCrossedHandler);
+        }
+        $this->assertInstanceOf(StreamHandler::class, $expectedStreamHandler);
+        $this->assertEquals(Monolog::DEBUG, $expectedStreamHandler->getLevel());
+    }
 }