Skip to content

Commit 13220d7

Browse files
bhuvidyatpetry
andcommitted
partial upserts (resolves #57)
Co-authored-by: tpetry <[email protected]>
1 parent a242704 commit 13220d7

8 files changed

+253
-1
lines changed

README.md

+8
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,14 @@ Schema::table('users', function(Blueprint $table) {
470470

471471
Partial Indexes are created with the `where` method on an index created by `fullText()`, `index()`, `spatialIndex()` or `uniqueIndex()`.
472472

473+
> [!TIP]
474+
> The `upsert()` method will not work with partial indexes because the condition needs to also be applied to the upsert.
475+
> You can use the `upsertPartial()` method for this:
476+
> ```php
477+
> User::upsertPartial($users, ['email'], ['name', 'subscriptions'], 'deleted_at is null');
478+
> User::upsertPartial($users, ['email'], ['name', 'subscriptions'], fn($query) => $query->whereNull('deleted_at'));
479+
> ```
480+
473481
#### Include Columns
474482
475483
A really great feature of recent PostgreSQL versions is the ability to include columns in an index as non-key columns.

phpstan-baseline.neon

+13-1
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,22 @@ parameters:
3636
count: 1
3737
path: src/Eloquent/Mixins/BuilderReturning.php
3838

39+
-
40+
message: '#^Call to protected method addTimestampsToUpsertValues\(\) of class Illuminate\\Database\\Eloquent\\Builder\<Illuminate\\Database\\Eloquent\\Model\>\.$#'
41+
identifier: method.protected
42+
count: 1
43+
path: src/Eloquent/Mixins/BuilderUpsertPartial.php
44+
45+
-
46+
message: '#^Call to protected method addUpdatedAtToUpsertColumns\(\) of class Illuminate\\Database\\Eloquent\\Builder\<Illuminate\\Database\\Eloquent\\Model\>\.$#'
47+
identifier: method.protected
48+
count: 1
49+
path: src/Eloquent/Mixins/BuilderUpsertPartial.php
50+
3951
-
4052
message: '#^Call to function method_exists\(\) with \$this\(Tpetry\\PostgresqlEnhanced\\Query\\Builder\) and ''applyBeforeQueryCal…'' will always evaluate to true\.$#'
4153
identifier: function.alreadyNarrowedType
42-
count: 7
54+
count: 8
4355
path: src/Query/Builder.php
4456

4557
-
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tpetry\PostgresqlEnhanced\Eloquent\Mixins;
6+
7+
use Closure;
8+
9+
/** @mixin \Illuminate\Database\Eloquent\Builder */
10+
class BuilderUpsertPartial
11+
{
12+
public function upsertPartial(): Closure
13+
{
14+
return function (array $values, array|string $uniqueBy, ?array $update, string|callable $where): int {
15+
/* @var \Illuminate\Database\Eloquent\Builder $this */
16+
if (0 === \count($values)) {
17+
return 0;
18+
}
19+
20+
if (null === $update) {
21+
$update = array_keys(reset($values));
22+
}
23+
24+
return $this->toBase()->upsertPartial(
25+
$this->addTimestampsToUpsertValues($values),
26+
$uniqueBy,
27+
$this->addUpdatedAtToUpsertColumns($update),
28+
$where
29+
);
30+
};
31+
}
32+
}

src/PostgresqlEnhancedServiceProvider.php

+2
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use PDO;
1515
use Tpetry\PostgresqlEnhanced\Eloquent\Mixins\BuilderLazyByCursor;
1616
use Tpetry\PostgresqlEnhanced\Eloquent\Mixins\BuilderReturning;
17+
use Tpetry\PostgresqlEnhanced\Eloquent\Mixins\BuilderUpsertPartial;
1718
use Tpetry\PostgresqlEnhanced\Support\Helpers\ZeroDowntimeMigrationSupervisor;
1819
use Tpetry\PostgresqlEnhanced\Types\BitType;
1920
use Tpetry\PostgresqlEnhanced\Types\CidrType;
@@ -89,6 +90,7 @@ public function register(): void
8990
{
9091
EloquentBuilder::mixin(new BuilderLazyByCursor());
9192
EloquentBuilder::mixin(new BuilderReturning());
93+
EloquentBuilder::mixin(new BuilderUpsertPartial());
9294

9395
Connection::resolverFor('pgsql', function (PDO|Closure $pdo, string $database = '', string $tablePrefix = '', array $config = []) {
9496
return new PostgresEnhancedConnection($pdo, $database, $tablePrefix, $config);

src/Query/Builder.php

+1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ class Builder extends BaseBuilder
1919
use BuilderLazyByCursor;
2020
use BuilderOrder;
2121
use BuilderReturning;
22+
use BuilderUpsertPartial;
2223
use BuilderWhere;
2324

2425
/**

src/Query/BuilderUpsertPartial.php

+61
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tpetry\PostgresqlEnhanced\Query;
6+
7+
use Closure;
8+
use Illuminate\Support\Arr;
9+
use Illuminate\Support\Collection;
10+
use Tpetry\PostgresqlEnhanced\Support\Helpers\Query;
11+
12+
trait BuilderUpsertPartial
13+
{
14+
/**
15+
* Insert new records or update the existing ones.
16+
*
17+
* @param string|(callable(\Illuminate\Database\Query\Builder):mixed)|(callable(\Illuminate\Contracts\Database\Query\Builder):mixed) $where
18+
*/
19+
public function upsertPartial(array $values, array|string $uniqueBy, ?array $update, string|callable $where): int
20+
{
21+
if (empty($values)) {
22+
return 0;
23+
} elseif ([] === $update) {
24+
return (int) $this->insert($values);
25+
}
26+
27+
if (!\is_array(reset($values))) {
28+
$values = [$values];
29+
} else {
30+
foreach ($values as $key => $value) {
31+
ksort($value);
32+
33+
$values[$key] = $value;
34+
}
35+
}
36+
37+
if (null === $update) {
38+
$update = array_keys(reset($values));
39+
}
40+
41+
if (method_exists($this, 'applyBeforeQueryCallbacks')) {
42+
$this->applyBeforeQueryCallbacks();
43+
}
44+
45+
$bindings = $this->cleanBindings([
46+
...Arr::flatten($values, 1),
47+
...(new Collection($update))->reject(fn ($value, $key) => \is_int($key))->all(),
48+
]);
49+
50+
$upsert = $this->grammar->compileUpsert($this, $values, (array) $uniqueBy, $update);
51+
if ($where instanceof Closure) {
52+
$query = $where($this->getConnection()->query());
53+
$where = trim(str_replace('select * where', '', Query::toSql($query)));
54+
}
55+
56+
return $this->connection->affectingStatement(
57+
str_replace('do update set', "where {$where} do update set", $upsert),
58+
$bindings
59+
);
60+
}
61+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tpetry\PostgresqlEnhanced\Tests\Eloquent;
6+
7+
use Carbon\Carbon;
8+
use Composer\Semver\Comparator;
9+
use Illuminate\Database\Eloquent\Model;
10+
use Illuminate\Database\Query\Builder;
11+
use Tpetry\PostgresqlEnhanced\Tests\TestCase;
12+
13+
class BuilderUpsertPartialTest extends TestCase
14+
{
15+
protected function setUp(): void
16+
{
17+
parent::setUp();
18+
19+
Carbon::setTestNow(); // only compatible way of freezing time in Laravel 6
20+
$this->getConnection()->unprepared('
21+
CREATE TABLE example (
22+
id bigint NOT NULL GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
23+
str text NOT NULL,
24+
val int NOT NULL,
25+
created_at timestamptz,
26+
updated_at timestamptz,
27+
deleted_at timestamptz
28+
);
29+
CREATE UNIQUE INDEX example_partial ON example (str) WHERE deleted_at IS NULL;
30+
');
31+
}
32+
33+
public function testUpsertPartialWhereQuery(): void
34+
{
35+
if (Comparator::lessThan($this->app->version(), '8.10.0')) {
36+
$this->markTestSkipped('Upsert() has been added in a later Laravel version.');
37+
}
38+
39+
$queries = $this->withQueryLog(function (): void {
40+
$result = (new ExamplePartial())
41+
->newQuery()
42+
->upsertPartial([['str' => 'JKLkmraa', 'val' => 849351]], ['str'], ['val'], fn (Builder $query) => $query->whereNull('deleted_at'));
43+
44+
$this->assertEquals(1, $result);
45+
});
46+
$this->assertEquals(['insert into "example" ("str", "val") values (?, ?) on conflict ("str") where "deleted_at" is null do update set "val" = "excluded"."val"'], array_column($queries, 'query'));
47+
}
48+
49+
public function testUpsertPartialWhereString(): void
50+
{
51+
if (Comparator::lessThan($this->app->version(), '8.10.0')) {
52+
$this->markTestSkipped('Upsert() has been added in a later Laravel version.');
53+
}
54+
55+
$queries = $this->withQueryLog(function (): void {
56+
$result = (new ExamplePartial())
57+
->newQuery()
58+
->upsertPartial([['str' => 'kIhqPWDC', 'val' => 623169]], ['str'], ['val'], 'deleted_at IS NULL');
59+
60+
$this->assertEquals(1, $result);
61+
});
62+
$this->assertEquals(['insert into "example" ("str", "val") values (?, ?) on conflict ("str") where deleted_at IS NULL do update set "val" = "excluded"."val"'], array_column($queries, 'query'));
63+
}
64+
}
65+
66+
class ExamplePartial extends Model
67+
{
68+
public $dateFormat = 'Y-m-d H:i:sO';
69+
public $guarded = [];
70+
public $table = 'example';
71+
public $timestamps = false;
72+
}

tests/Query/UpsertPartialTest.php

+64
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Query;
6+
7+
use Composer\Semver\Comparator;
8+
use Illuminate\Database\Query\Builder;
9+
use Tpetry\PostgresqlEnhanced\Tests\TestCase;
10+
11+
class UpsertPartialTest extends TestCase
12+
{
13+
protected function setUp(): void
14+
{
15+
parent::setUp();
16+
17+
$this->getConnection()->unprepared('
18+
CREATE TABLE example (
19+
col int NOT NULL,
20+
val int NOT NULL,
21+
deleted_at timestamptz
22+
);
23+
CREATE UNIQUE INDEX example_partial ON example (col) WHERE deleted_at IS NULL;
24+
');
25+
}
26+
27+
public function testUpsertPartialWhereQuery(): void
28+
{
29+
if (Comparator::lessThan($this->app->version(), '8.10.0')) {
30+
$this->markTestSkipped('Upsert() has been added in a later Laravel version.');
31+
}
32+
33+
$queries = $this->withQueryLog(function (): void {
34+
$result = $this->getConnection()
35+
->table('example')
36+
->upsertPartial([['col' => 446737, 'val' => 896013], ['col' => 719244, 'val' => 572449]], ['col'], ['val'], fn (Builder $query) => $query->whereNull('deleted_at'));
37+
38+
$this->assertEquals(2, $result);
39+
});
40+
41+
$this->assertEquals([
42+
'insert into "example" ("col", "val") values (?, ?), (?, ?) on conflict ("col") where "deleted_at" is null do update set "val" = "excluded"."val"',
43+
], array_column($queries, 'query'));
44+
}
45+
46+
public function testUpsertPartialWhereString(): void
47+
{
48+
if (Comparator::lessThan($this->app->version(), '8.10.0')) {
49+
$this->markTestSkipped('Upsert() has been added in a later Laravel version.');
50+
}
51+
52+
$queries = $this->withQueryLog(function (): void {
53+
$result = $this->getConnection()
54+
->table('example')
55+
->upsertPartial([['col' => 278449, 'val' => 733801], ['col' => 335775, 'val' => 120552]], ['col'], ['val'], 'deleted_at IS NULL');
56+
57+
$this->assertEquals(2, $result);
58+
});
59+
60+
$this->assertEquals([
61+
'insert into "example" ("col", "val") values (?, ?), (?, ?) on conflict ("col") where deleted_at IS NULL do update set "val" = "excluded"."val"',
62+
], array_column($queries, 'query'));
63+
}
64+
}

0 commit comments

Comments
 (0)