├── .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 [](https://github.com/mpyw/laravel-retry-on-duplicate-key/actions) [](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 |
--------------------------------------------------------------------------------