Repository: laravel/framework (php)

Issue:
Job batch doesn't work properly with PostgreSQL
<!-- DO NOT THROW THIS AWAY -->
<!-- Fill out the FULL versions with patch versions -->

- Laravel Version: 8.24.0
- PHP Version: 7.4.13
- Database Driver & Version: PostgreSQL 13.0

### Description:
A serialized object in PHP that has null values before and after private fields like will fail to serialize because Postgres PDO serialization will stop on null values.
Example:  
`"O:1:"A":1:{s:8:"\0A\0field";N;}"`

This is because of [PQescapeStringConn](https://www.postgresql.org/docs/current/libpq-exec.html)  
> A terminating zero byte is not required, and should not be counted in length. (**If a terminating zero byte is found before length bytes are processed, PQescapeStringConn stops at the zero;** the behavior is thus rather like strncpy.) 

Therefore, if we need to store a serialized object into a text Postgres column, it will fail.

Laravel uses serialization for `job_batches.options` and when used with PostgresSQL causes this error:   
```php
 ErrorException 

  Insufficient data for unserializing - 273 required, 90 present

  at vendor/laravel/framework/src/Illuminate/Bus/DatabaseBatchRepository.php:286
    282▕             (int) $batch->total_jobs,
    283▕             (int) $batch->pending_jobs,
    284▕             (int) $batch->failed_jobs,
    285▕             json_decode($batch->failed_job_ids, true),
  ➜ 286▕             unserialize($batch->options),
    287▕             CarbonImmutable::createFromTimestamp($batch->created_at),
    288▕             $batch->cancelled_at ? CarbonImmutable::createFromTimestamp($batch->cancelled_at) : $batch->cancelled_at,
    289▕             $batch->finished_at ? CarbonImmutable::createFromTimestamp($batch->finished_at) : $batch->finished_at
    290▕         );

      +4 vendor frames 
 ```


### Steps To Reproduce:
```php
class A {
    private $field;
}

$a = new A();

Bus::batch([])
    ->then(fn ($batch) => $a)
    ->dispatch();
```

### Possible Fix
We can use base64_encode before inserting & base64_decode after fetching the serialized objects.

If you find this solution ok let me know and I'll create a PR for it.

Thank you. 

References:   
[PostgreSQL: Chapter 33. libpq — C Library](https://www.postgresql.org/docs/current/libpq-exec.html)   
[Text column type doesn't reliably hold serialized variables](https://www.drupal.org/project/drupal/issues/690746)

Pull request:
[8.x] Fixes job batch serialization for PostgreSQL (#36061)
<!--
Please only send a pull request to branches which are currently supported: https://laravel.com/docs/releases#support-policy 

If you are unsure which branch your pull request should be sent to, please read: https://laravel.com/docs/contributions#which-branch

Pull requests without a descriptive title, thorough description, or tests will be closed.

In addition, please describe the benefit to end users; the reasons it does not break any existing features; how it makes building web applications easier, etc.
-->
This pull request resolves #36061, by using base64 encoding in `DatabaseBatchRepository` for PostgreSQL.

Patch:
diff --git a/src/Illuminate/Bus/DatabaseBatchRepository.php b/src/Illuminate/Bus/DatabaseBatchRepository.php
index ece6301c12..4dd5db261b 100644
--- a/src/Illuminate/Bus/DatabaseBatchRepository.php
+++ b/src/Illuminate/Bus/DatabaseBatchRepository.php
@@ -6,6 +6,7 @@
 use Closure;
 use DateTimeInterface;
 use Illuminate\Database\Connection;
+use Illuminate\Database\PostgresConnection;
 use Illuminate\Database\Query\Expression;
 use Illuminate\Support\Str;
 
@@ -102,7 +103,7 @@ public function store(PendingBatch $batch)
             'pending_jobs' => 0,
             'failed_jobs' => 0,
             'failed_job_ids' => '[]',
-            'options' => serialize($batch->options),
+            'options' => $this->serialize($batch->options),
             'created_at' => time(),
             'cancelled_at' => null,
             'finished_at' => null,
@@ -283,10 +284,40 @@ protected function toBatch($batch)
             (int) $batch->pending_jobs,
             (int) $batch->failed_jobs,
             json_decode($batch->failed_job_ids, true),
-            unserialize($batch->options),
+            $this->unserialize($batch->options),
             CarbonImmutable::createFromTimestamp($batch->created_at),
             $batch->cancelled_at ? CarbonImmutable::createFromTimestamp($batch->cancelled_at) : $batch->cancelled_at,
             $batch->finished_at ? CarbonImmutable::createFromTimestamp($batch->finished_at) : $batch->finished_at
         );
     }
+
+    /**
+     * Serialize the given value.
+     *
+     * @param  mixed  $value
+     * @return string
+     */
+    protected function serialize($value)
+    {
+        $serialized = serialize($value);
+
+        return $this->connection instanceof PostgresConnection
+            ? base64_encode($serialized)
+            : $serialized;
+    }
+
+    /**
+     * Unserialize the given value.
+     *
+     * @param  string  $serialized
+     * @return mixed
+     */
+    protected function unserialize($serialized)
+    {
+        if ($this->connection instanceof PostgresConnection && ! Str::contains($serialized, [':', ';'])) {
+            $serialized = base64_decode($serialized);
+        }
+
+        return unserialize($serialized);
+    }
 }


Test patch:
diff --git a/tests/Bus/BusBatchTest.php b/tests/Bus/BusBatchTest.php
index 8f4d6c5a11..1da999569e 100644
--- a/tests/Bus/BusBatchTest.php
+++ b/tests/Bus/BusBatchTest.php
@@ -14,6 +14,7 @@
 use Illuminate\Contracts\Queue\ShouldQueue;
 use Illuminate\Database\Capsule\Manager as DB;
 use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\PostgresConnection;
 use Illuminate\Foundation\Bus\Dispatchable;
 use Illuminate\Queue\CallQueuedClosure;
 use Mockery as m;
@@ -339,6 +340,72 @@ public function test_chain_can_be_added_to_batch()
         $this->assertInstanceOf(CarbonImmutable::class, $batch->createdAt);
     }
 
+    public function test_options_serialization_on_postgres()
+    {
+        $pendingBatch = (new PendingBatch(new Container, collect()))
+            ->onQueue('test-queue');
+
+        $connection = m::spy(PostgresConnection::class);
+
+        $connection->shouldReceive('table')->andReturnSelf()
+            ->shouldReceive('where')->andReturnSelf();
+
+        $repository = new DatabaseBatchRepository(
+            new BatchFactory(m::mock(Factory::class)), $connection, 'job_batches'
+        );
+
+        $repository->store($pendingBatch);
+
+        $connection->shouldHaveReceived('insert')
+            ->withArgs(function ($argument) use ($pendingBatch) {
+                return unserialize(base64_decode($argument['options'])) === $pendingBatch->options;
+            });
+    }
+
+    /**
+     * @dataProvider serializedOptions
+     */
+    public function test_options_unserialize_on_postgres($serialize, $options)
+    {
+        $factory = m::mock(BatchFactory::class);
+
+        $connection = m::spy(PostgresConnection::class);
+
+        $connection->shouldReceive('table->where->first')
+            ->andReturn($m = (object) [
+                'id' => '',
+                'name' => '',
+                'total_jobs' => '',
+                'pending_jobs' => '',
+                'failed_jobs' => '',
+                'failed_job_ids' => '[]',
+                'options' => $serialize,
+                'created_at' => null,
+                'cancelled_at' => null,
+                'finished_at' => null,
+            ]);
+
+        $batch = (new DatabaseBatchRepository($factory, $connection, 'job_batches'));
+
+        $factory->shouldReceive('make')
+            ->withSomeOfArgs($batch, '', '', '', '', '', '', $options);
+
+        $batch->find(1);
+    }
+
+    /**
+     * @return array
+     */
+    public function serializedOptions()
+    {
+        $options = [1, 2];
+
+        return [
+            [serialize($options), $options],
+            [base64_encode(serialize($options)), $options],
+        ];
+    }
+
     protected function createTestBatch($queue, $allowFailures = false)
     {
         $repository = new DatabaseBatchRepository(new BatchFactory($queue), DB::connection(), 'job_batches');