├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── README.md ├── _ide_helper.php ├── composer.json ├── extension.neon ├── phpstan ├── CallableArgumentParameter.php ├── CallableFacadeReturnTypeExtension.php ├── CallableParameter.php ├── CallableReturnTypeExtension.php ├── ConnectionClassExtension.php └── RetryOnDuplicateKeyMethod.php ├── phpunit.xml ├── src ├── ConnectionServiceProvider.php ├── Connections │ ├── MySqlConnection.php │ ├── PostgresConnection.php │ ├── SQLiteConnection.php │ └── SqlServerConnection.php ├── RetriesOnDuplicateKey.php └── RetryOnDuplicateKey.php └── tests ├── BasicTest.php ├── Models ├── Model.php ├── Post.php └── User.php ├── TestCase.php ├── TransactionErrorRecoveryTest.php ├── TransactionErrorRefreshDatabaseRecoveryTest.php └── config ├── database.github.php └── database.php /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | services: 10 | mysql: 11 | image: mysql:8.0 12 | ports: 13 | - '3306:3306' 14 | env: 15 | MYSQL_DATABASE: testing 16 | MYSQL_USER: testing 17 | MYSQL_PASSWORD: testing 18 | MYSQL_ROOT_PASSWORD: testing 19 | options: >- 20 | --health-cmd="mysqladmin ping" 21 | --health-interval=10s 22 | --health-timeout=30s 23 | --health-retries=5 24 | postgres: 25 | image: postgres:13.3 26 | ports: 27 | - '5432:5432' 28 | env: 29 | POSTGRES_DB: testing 30 | POSTGRES_USER: testing 31 | POSTGRES_PASSWORD: testing 32 | options: >- 33 | --health-cmd=pg_isready 34 | --health-interval=10s 35 | --health-timeout=30s 36 | --health-retries=5 37 | sqlsrv: 38 | image: mcr.microsoft.com/mssql/server:2019-latest 39 | ports: 40 | - '1433:1433' 41 | env: 42 | ACCEPT_EULA: Y 43 | SA_PASSWORD: Password! 44 | options: >- 45 | --name sqlsrv 46 | --health-cmd "echo quit | /opt/mssql-tools/bin/sqlcmd -S 127.0.0.1 -l 1 -U sa -P Password!" 47 | 48 | strategy: 49 | matrix: 50 | php: ['8.0', 8.1, 8.2] 51 | lib: 52 | - { laravel: ^11.0 } 53 | - { laravel: ^10.0 } 54 | - { laravel: ^9.0 } 55 | db: [mysql, pgsql, sqlite, sqlsrv] 56 | odbc: [odbc, ''] 57 | exclude: 58 | # ignore odbc except for sqlsrv 59 | - { db: mysql, odbc: odbc } 60 | - { db: pgsql, odbc: odbc } 61 | - { db: sqlite, odbc: odbc } 62 | # ignore old PHP for newer Laravel 63 | - { php: 8.0, lib: { laravel: ^10.0 } } 64 | - { php: 8.0, lib: { laravel: ^11.0 } } 65 | - { php: 8.1, lib: { laravel: ^11.0 } } 66 | include: 67 | - { lib: { laravel: ^9.0 }, phpstan: 1 } 68 | - { lib: { laravel: ^10.0 }, phpstan: 1 } 69 | 70 | steps: 71 | - uses: actions/checkout@v3 72 | 73 | - name: Setup PHP 74 | uses: shivammathur/setup-php@v2 75 | with: 76 | php-version: ${{ matrix.php }} 77 | coverage: xdebug 78 | 79 | - name: Set up SQLServer 80 | if: matrix.db == 'sqlsrv' 81 | run: | 82 | docker exec sqlsrv \ 83 | /opt/mssql-tools/bin/sqlcmd \ 84 | -S 127.0.0.1 \ 85 | -U sa \ 86 | -P Password! \ 87 | -Q "create database [testing]" 88 | 89 | - name: Remove impossible dependencies 90 | if: ${{ matrix.phpstan != 1 }} 91 | run: composer remove nunomaduro/larastan --dev --no-update 92 | 93 | - name: Adjust Package Versions 94 | run: | 95 | composer require "laravel/framework:${{ matrix.lib.laravel }}" --dev --no-update 96 | composer update 97 | 98 | - name: Prepare Database Config 99 | run: mv tests/config/database.github.php tests/config/database.php 100 | 101 | - name: Prepare Coverage Directory 102 | run: mkdir -p build/logs 103 | 104 | - name: PHPStan 105 | if: ${{ matrix.phpstan == 1 }} 106 | run: composer phpstan 107 | 108 | - name: Test 109 | run: composer test -- --coverage-clover build/logs/clover.xml 110 | env: 111 | DB: ${{ matrix.db }} 112 | ENABLE_POLYFILL: ${{ matrix.lib.polyfill }} 113 | ENABLE_ODBC: ${{ matrix.odbc }} 114 | 115 | - name: Upload Coverage 116 | uses: nick-invision/retry@v2 117 | env: 118 | COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} 119 | COVERALLS_PARALLEL: 'true' 120 | COVERALLS_FLAG_NAME: "db:${{ matrix.db }} odbc:${{ matrix.odbc || 'none' }} laravel:${{ matrix.lib.laravel }} php:${{ matrix.php }}" 121 | with: 122 | timeout_minutes: 1 123 | max_attempts: 3 124 | command: | 125 | composer global require php-coveralls/php-coveralls 126 | php-coveralls --coverage_clover=build/logs/clover.xml -v 127 | 128 | coverage-aggregation: 129 | needs: build 130 | runs-on: ubuntu-latest 131 | steps: 132 | - name: Aggregate Coverage 133 | uses: coverallsapp/github-action@master 134 | with: 135 | github-token: ${{ secrets.GITHUB_TOKEN }} 136 | parallel-finished: true 137 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | composer.lock 2 | /.idea/ 3 | /vendor/ 4 | /build/logs/ 5 | .php_cs.cache 6 | /.phpunit.cache/ 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 mpyw 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Retry on Duplicate Key [![Build Status](https://github.com/mpyw/laravel-retry-on-duplicate-key/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/mpyw/laravel-retry-on-duplicate-key/actions) [![Coverage Status](https://coveralls.io/repos/github/mpyw/laravel-retry-on-duplicate-key/badge.svg?branch=master)](https://coveralls.io/github/mpyw/laravel-retry-on-duplicate-key?branch=master) 2 | 3 | > [!CAUTION] 4 | > **ABANDONED: Due to changes in Laravel [v10.29.0](https://github.com/laravel/framework/releases/tag/v10.29.0), the functionalities of this library have been integrated into the Laravel core, making the library unnecessary in most cases. Therefore, maintenance will be discontinued. From now on, retry processing will be automatically performed in `Model::createOrFirst()`, `Model::firstOrCreate()`, and `Model::updateOrCreate()`. The only pattern that still has value is for `Model::firstOrNew() + save()`, but since equivalent processing can be written by yourself, please do not use this library anymore.** 5 | 6 | Automatically retry **non-atomic** upsert operation when unique constraints are violated. 7 | 8 | e.g. `firstOrCreate()` `updateOrCreate()` `firstOrNew() + save()` 9 | 10 | Original Issue: [Duplicate entries on updateOrCreate · Issue #19372 · laravel/framework](https://github.com/laravel/framework/issues/19372#issuecomment-584676368) 11 | 12 | ## Requirements 13 | 14 | | Package | Version | Mandatory | 15 | |:----------------------------------------------------------------------------------------------------|:-------------------------------------|:---------:| 16 | | PHP | ^8.0 | ✅ | 17 | | Laravel | ^9.0 || ^10.0 | ✅ | 18 | | [mpyw/laravel-unique-violation-detector](https://github.com/mpyw/laravel-unique-violation-detector) | ^1.0 | ✅ | 19 | | PHPStan | >=1.1 | | 20 | 21 | ## Installing 22 | 23 | ``` 24 | composer require mpyw/laravel-retry-on-duplicate-key 25 | ``` 26 | 27 | ## Basic usage 28 | 29 | > [!IMPORTANT] 30 | > The default implementation is provided by `ConnectionServiceProvider`, however, **package discovery is not available**. 31 | Be careful that you MUST register it in **`config/app.php`** by yourself. 32 | 33 | ```php 34 | [ 41 | /* ... */ 42 | 43 | Mpyw\LaravelRetryOnDuplicateKey\ConnectionServiceProvider::class, 44 | 45 | /* ... */ 46 | ], 47 | 48 | ]; 49 | ``` 50 | 51 | ```php 52 | 'example.com'], ['name' => 'Example']); 59 | }); 60 | ``` 61 | 62 | | OTHER | YOU | 63 | |:----:|:----:| 64 | | SELECT
(No Results) | | 65 | | ︙ | | 66 | | ︙ | SELECT
(No Results) | 67 | | ︙ | ︙ | 68 | | INSERT
(OK) | ︙ | 69 | | | ︙ | 70 | | | INSERT
(Error! Duplicate entry) | 71 | | | Prepare for the next retry, referring to primary connection | 72 | | | SELECT
(1 Result) | 73 | 74 | 75 | ## Advanced Usage 76 | 77 | You can extend Connection classes with `RetriesOnDuplicateKey` trait by yourself. 78 | 79 | ```php 80 | =7.0", 35 | "phpunit/phpunit": ">=9.5", 36 | "phpstan/phpstan": ">=1.1", 37 | "phpstan/extension-installer": ">=1.1", 38 | "nunomaduro/larastan": ">=1.0" 39 | }, 40 | "scripts": { 41 | "test": "vendor/bin/phpunit", 42 | "phpstan": "vendor/bin/phpstan analyse --level=9 --configuration=extension.neon src tests phpstan" 43 | }, 44 | "minimum-stability": "dev", 45 | "prefer-stable": true, 46 | "extra": { 47 | "phpstan": { 48 | "includes": [ 49 | "extension.neon" 50 | ] 51 | } 52 | }, 53 | "config": { 54 | "allow-plugins": { 55 | "phpstan/extension-installer": true 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /extension.neon: -------------------------------------------------------------------------------- 1 | services: 2 | - 3 | class: Mpyw\LaravelRetryOnDuplicateKey\PHPStan\ConnectionClassExtension 4 | tags: 5 | - phpstan.broker.methodsClassReflectionExtension 6 | - 7 | class: Mpyw\LaravelRetryOnDuplicateKey\PHPStan\CallableReturnTypeExtension 8 | tags: 9 | - phpstan.broker.dynamicMethodReturnTypeExtension 10 | - 11 | class: Mpyw\LaravelRetryOnDuplicateKey\PHPStan\CallableFacadeReturnTypeExtension 12 | tags: 13 | - phpstan.broker.dynamicStaticMethodReturnTypeExtension 14 | -------------------------------------------------------------------------------- /phpstan/CallableArgumentParameter.php: -------------------------------------------------------------------------------- 1 | getName() === 'retryOnDuplicateKey'; 25 | } 26 | 27 | public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, Scope $scope): \PHPStan\Type\Type 28 | { 29 | if (\count($methodCall->getArgs()) > 0) { 30 | $type = $scope->getType($methodCall->getArgs()[0]->value); 31 | 32 | if ($type instanceof ParametersAcceptor) { 33 | return $type->getReturnType(); 34 | } 35 | } 36 | 37 | return new MixedType(); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /phpstan/CallableParameter.php: -------------------------------------------------------------------------------- 1 | argumentParameters = $argumentParameters; 25 | } 26 | 27 | public function getName(): string 28 | { 29 | return 'callback'; 30 | } 31 | 32 | public function isOptional(): bool 33 | { 34 | return false; 35 | } 36 | 37 | public function getType(): Type 38 | { 39 | return new CallableType($this->argumentParameters); 40 | } 41 | 42 | public function passedByReference(): PassedByReference 43 | { 44 | return PassedByReference::createNo(); 45 | } 46 | 47 | public function isVariadic(): bool 48 | { 49 | return false; 50 | } 51 | 52 | public function getDefaultValue(): ?Type 53 | { 54 | return null; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /phpstan/CallableReturnTypeExtension.php: -------------------------------------------------------------------------------- 1 | getName() === 'retryOnDuplicateKey'; 26 | } 27 | 28 | public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): Type 29 | { 30 | if (\count($methodCall->getArgs()) > 0) { 31 | $type = $scope->getType($methodCall->getArgs()[0]->value); 32 | 33 | if ($type instanceof ParametersAcceptor) { 34 | return $type->getReturnType(); 35 | } 36 | } 37 | 38 | return new MixedType(); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /phpstan/ConnectionClassExtension.php: -------------------------------------------------------------------------------- 1 | getName(), ConnectionInterface::class, true); 18 | } 19 | 20 | public function getMethod(ClassReflection $classReflection, string $methodName): MethodReflection 21 | { 22 | return new RetryOnDuplicateKeyMethod($classReflection); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /phpstan/RetryOnDuplicateKeyMethod.php: -------------------------------------------------------------------------------- 1 | class = $classReflection; 25 | } 26 | 27 | public function getDeclaringClass(): ClassReflection 28 | { 29 | return $this->class; 30 | } 31 | 32 | public function isStatic(): bool 33 | { 34 | return false; 35 | } 36 | 37 | public function isPrivate(): bool 38 | { 39 | return false; 40 | } 41 | 42 | public function isPublic(): bool 43 | { 44 | return true; 45 | } 46 | 47 | public function getDocComment(): ?string 48 | { 49 | return null; 50 | } 51 | 52 | public function getName(): string 53 | { 54 | return 'retryOnDuplicateKey'; 55 | } 56 | 57 | public function getPrototype(): ClassMemberReflection 58 | { 59 | return $this; 60 | } 61 | 62 | public function getVariants(): array 63 | { 64 | $variants = []; 65 | 66 | for ($i = 0; $i < 10; ++$i) { 67 | $argumentParameters = []; 68 | for ($j = 0; $j < $i; ++$j) { 69 | $argumentParameters[] = new CallableArgumentParameter(); 70 | } 71 | 72 | $variants[] = new FunctionVariant( 73 | TemplateTypeMap::createEmpty(), 74 | null, 75 | [ 76 | new CallableParameter($argumentParameters), 77 | ...$argumentParameters, 78 | ], 79 | false, 80 | new MixedType(), 81 | ); 82 | } 83 | 84 | return $variants; 85 | } 86 | 87 | public function isDeprecated(): TrinaryLogic 88 | { 89 | return TrinaryLogic::createNo(); 90 | } 91 | 92 | public function getDeprecatedDescription(): ?string 93 | { 94 | return null; 95 | } 96 | 97 | public function isFinal(): TrinaryLogic 98 | { 99 | return TrinaryLogic::createNo(); 100 | } 101 | 102 | public function isInternal(): TrinaryLogic 103 | { 104 | return TrinaryLogic::createNo(); 105 | } 106 | 107 | public function getThrowType(): ?Type 108 | { 109 | return new ObjectType(QueryException::class); 110 | } 111 | 112 | public function hasSideEffects(): TrinaryLogic 113 | { 114 | return TrinaryLogic::createMaybe(); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | src 6 | 7 | 8 | 9 | 10 | ./tests/ 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/ConnectionServiceProvider.php: -------------------------------------------------------------------------------- 1 | new MySqlConnection(...$args)); 22 | Connection::resolverFor('pgsql', fn (...$args) => new PostgresConnection(...$args)); 23 | Connection::resolverFor('sqlite', fn (...$args) => new SQLiteConnection(...$args)); 24 | Connection::resolverFor('sqlsrv', fn (...$args) => new SqlServerConnection(...$args)); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Connections/MySqlConnection.php: -------------------------------------------------------------------------------- 1 | connection = $connection; 20 | } 21 | 22 | /** 23 | * Retries once on duplicate key errors. 24 | * 25 | * @param mixed ...$args 26 | * @return mixed 27 | */ 28 | public function __invoke(callable $callback, ...$args) 29 | { 30 | try { 31 | return $this->withSavepoint(fn () => $callback(...$args)); 32 | } catch (PDOException $e) { 33 | if ((new UniqueViolationDetector($this->connection))->violated($e)) { 34 | $this->forceReferringPrimaryConnection(); 35 | return $this->withSavepoint(fn () => $callback(...$args)); 36 | } 37 | throw $e; 38 | } 39 | } 40 | 41 | /** 42 | * Make sure to fetch the latest data on the next try. 43 | */ 44 | protected function forceReferringPrimaryConnection(): void 45 | { 46 | $connection = $this->connection; 47 | 48 | if ($connection instanceof Connection) { 49 | $connection->recordsHaveBeenModified(); 50 | } 51 | } 52 | 53 | /** 54 | * @phpstan-template T 55 | * @phpstan-param callable(): T $callback 56 | * @phpstan-return T 57 | * @return mixed 58 | * @noinspection PhpDocMissingThrowsInspection 59 | * @noinspection PhpUnhandledExceptionInspection 60 | */ 61 | protected function withSavepoint(callable $callback) 62 | { 63 | return $this->needsSavepoint() 64 | ? $this->connection->transaction(fn () => $callback()) 65 | : $callback(); 66 | } 67 | 68 | protected function needsSavepoint(): bool 69 | { 70 | // In Postgres, savepoints allow recovery from errors. 71 | // This ensures retrying should work also in transactions. 72 | return $this->connection instanceof PostgresConnection 73 | && $this->connection->transactionLevel() > 0; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /tests/BasicTest.php: -------------------------------------------------------------------------------- 1 | db()->retryOnDuplicateKey(function () { 17 | static $tries = 0; 18 | 19 | $this->assertSame((bool)$tries++, $this->db()->hasModifiedRecords()); 20 | 21 | $user = new User(); 22 | $user->fill(['id' => 1, 'email' => 'example-another@example.com', 'type' => 'consumer'])->save(); 23 | }); 24 | $this->fail(); 25 | } catch (QueryException $e) { 26 | var_dump($e->errorInfo); 27 | $this->assertCount(2, $this->queries); 28 | } 29 | } 30 | 31 | public function testRetryOnDuplicateUniqueKey(): void 32 | { 33 | try { 34 | $this->db()->retryOnDuplicateKey(function () { 35 | static $tries = 0; 36 | 37 | $this->assertSame((bool)$tries++, $this->db()->hasModifiedRecords()); 38 | 39 | $user = new User(); 40 | $user->fill(['id' => 2, 'email' => 'example@example.com', 'type' => 'consumer'])->save(); 41 | }); 42 | $this->fail(); 43 | } catch (QueryException $e) { 44 | var_dump($e->errorInfo); 45 | $this->assertCount(2, $this->queries); 46 | } 47 | } 48 | 49 | public function testDontRetryOnForeignKeyConstraintViolation(): void 50 | { 51 | try { 52 | $this->db()->retryOnDuplicateKey(function () { 53 | static $tries = 0; 54 | 55 | $this->assertSame(0, $tries++); 56 | $this->assertFalse($this->db()->hasModifiedRecords()); 57 | 58 | $post = new Post(); 59 | $post->fill(['user_id' => 9999])->save(); 60 | }); 61 | $this->fail(); 62 | } catch (QueryException $e) { 63 | var_dump($e->errorInfo); 64 | $this->assertCount(1, $this->queries); 65 | } 66 | } 67 | 68 | public function testDontRetryOnEnumConstraintViolation(): void 69 | { 70 | try { 71 | $this->db()->retryOnDuplicateKey(function () { 72 | static $tries = 0; 73 | 74 | $this->assertSame(0, $tries++); 75 | $this->assertFalse($this->db()->hasModifiedRecords()); 76 | 77 | $user = new User(); 78 | $user->fill(['id' => 2, 'email' => 'example-another@example.com', 'type' => 'foo'])->save(); 79 | }); 80 | $this->fail(); 81 | } catch (QueryException $e) { 82 | var_dump($e->errorInfo); 83 | $this->assertCount(1, $this->queries); 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /tests/Models/Model.php: -------------------------------------------------------------------------------- 1 | getConnection() instanceof SqlServerConnection) { 23 | return 'Y-m-d H:i:s'; 24 | } 25 | 26 | return parent::getDateFormat(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tests/Models/Post.php: -------------------------------------------------------------------------------- 1 | require __DIR__ . '/config/database.php', 35 | 'database.default' => getenv('DB') ?: 'sqlite', 36 | ]); 37 | } 38 | 39 | protected function setUp(): void 40 | { 41 | parent::setUp(); 42 | 43 | if ($this->db()->getDriverName() === 'sqlite') { 44 | $this->db()->statement('PRAGMA foreign_keys=true;'); 45 | } 46 | 47 | // Workaround for https://github.com/laravel/framework/pull/35988 48 | $shouldRestartTransaction = false; 49 | if ($this->db()->getPdo()->inTransaction()) { 50 | $this->db()->getPdo()->commit(); 51 | $shouldRestartTransaction = true; 52 | } 53 | 54 | Schema::dropIfExists('posts'); 55 | Schema::dropIfExists('users'); 56 | 57 | Schema::create('users', function (Blueprint $table) { 58 | $table->integer('id')->primary(); 59 | $table->string('email')->unique(); 60 | $table->enum('type', ['consumer', 'provider']); 61 | $table->timestamps(); 62 | }); 63 | 64 | Schema::create('posts', function (Blueprint $table) { 65 | $table->increments('id'); 66 | $table->integer('user_id'); 67 | $table->foreign('user_id')->references('id')->on('users'); 68 | $table->timestamps(); 69 | }); 70 | 71 | // Workaround for https://github.com/laravel/framework/pull/35988 72 | if ($shouldRestartTransaction) { 73 | $this->db()->getPdo()->beginTransaction(); 74 | } 75 | 76 | $user = new User(); 77 | $user->fill(['id' => 1, 'email' => 'example@example.com', 'type' => 'consumer'])->save(); 78 | 79 | $this->db()->forgetRecordModificationState(); 80 | 81 | $this->db()->beforeExecuting(function (string $query) { 82 | $this->queries[] = $query; 83 | }); 84 | 85 | $this->queries = []; 86 | } 87 | 88 | protected function db(): Connection 89 | { 90 | $connection = App::make(Connection::class); 91 | assert($connection instanceof Connection); 92 | return $connection; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /tests/TransactionErrorRecoveryTest.php: -------------------------------------------------------------------------------- 1 | db()->transaction(function () { 19 | try { 20 | $this->db()->retryOnDuplicateKey(function () { 21 | static $tries = 0; 22 | 23 | $this->assertSame((bool)$tries++, $this->db()->hasModifiedRecords()); 24 | 25 | $user = new User(); 26 | $user->fill(['id' => 2, 'email' => 'example@example.com', 'type' => 'consumer'])->save(); 27 | }); 28 | } catch (QueryException $e) { 29 | var_dump($e->errorInfo); 30 | $this->assertCount(2, $this->queries); 31 | } 32 | 33 | $user = new User(); 34 | $user->fill(['id' => 2, 'email' => 'example-another@example.com', 'type' => 'consumer'])->save(); 35 | $this->assertCount(3, $this->queries); 36 | }); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /tests/TransactionErrorRefreshDatabaseRecoveryTest.php: -------------------------------------------------------------------------------- 1 | db()->retryOnDuplicateKey(function () { 23 | static $tries = 0; 24 | 25 | $this->assertSame((bool)$tries++, $this->db()->hasModifiedRecords()); 26 | 27 | $user = new User(); 28 | $user->fill(['id' => 2, 'email' => 'example@example.com', 'type' => 'consumer'])->save(); 29 | }); 30 | } catch (QueryException $e) { 31 | var_dump($e->errorInfo); 32 | $this->assertCount(2, $this->queries); 33 | } 34 | 35 | $user = new User(); 36 | $user->fill(['id' => 2, 'email' => 'example-another@example.com', 'type' => 'consumer'])->save(); 37 | $this->assertCount(3, $this->queries); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/config/database.github.php: -------------------------------------------------------------------------------- 1 | [ 7 | 'driver' => 'mysql', 8 | 'host' => '127.0.0.1', 9 | 'port' => '3306', 10 | 'database' => 'testing', 11 | 'username' => 'testing', 12 | 'password' => 'testing', 13 | 'unix_socket' => '', 14 | 'charset' => 'utf8mb4', 15 | 'collation' => 'utf8mb4_unicode_ci', 16 | 'prefix' => '', 17 | 'strict' => true, 18 | 'engine' => null, 19 | ], 20 | 'pgsql' => [ 21 | 'driver' => 'pgsql', 22 | 'host' => '127.0.0.1', 23 | 'port' => '5432', 24 | 'database' => 'testing', 25 | 'username' => 'testing', 26 | 'password' => 'testing', 27 | 'charset' => 'utf8', 28 | 'prefix' => '', 29 | 'schema' => 'public', 30 | 'sslmode' => 'prefer', 31 | ], 32 | 'sqlite' => [ 33 | 'driver' => 'sqlite', 34 | 'database' => ':memory:', 35 | 'prefix' => '', 36 | ], 37 | 'sqlsrv' => [ 38 | 'driver' => 'sqlsrv', 39 | 'host' => '127.0.0.1', 40 | 'port' => '1433', 41 | 'database' => 'testing', 42 | 'username' => 'sa', 43 | 'password' => 'Password!', 44 | 'charset' => 'utf8', 45 | 'prefix' => '', 46 | 'prefix_indexes' => true, 47 | 'odbc' => (bool)getenv('ENABLE_ODBC'), 48 | 'odbc_datasource_name' => 'Driver={ODBC Driver 17 for SQL Server};Server=127.0.0.1;Database=testing;UID=sa;PWD=Password!', 49 | ], 50 | ]; 51 | -------------------------------------------------------------------------------- /tests/config/database.php: -------------------------------------------------------------------------------- 1 | [ 7 | 'driver' => 'sqlite', 8 | 'database' => ':memory:', 9 | 'prefix' => '', 10 | ], 11 | ]; 12 | --------------------------------------------------------------------------------