Skip to content

Commit 8617bb2

Browse files
committed
Merge branch 'master' into 3.2-merge
# Conflicts: # .github/workflows/test.yml # src/grpc-client/src/BaseClient.php
2 parents 7a15850 + f6cc86d commit 8617bb2

12 files changed

+447
-61
lines changed

src/PostgreSqlConnection.php

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
namespace Hyperf\Database\PgSQL;
1414

15+
use DateTimeInterface;
1516
use Exception;
1617
use Hyperf\Database\Connection;
1718
use Hyperf\Database\PgSQL\DBAL\PostgresPdoDriver;
@@ -20,6 +21,7 @@
2021
use Hyperf\Database\PgSQL\Schema\Grammars\PostgresGrammar as SchemaGrammar;
2122
use Hyperf\Database\PgSQL\Schema\PostgresBuilder;
2223
use Hyperf\Database\Query\Grammars\PostgresGrammar;
24+
use PDO;
2325
use PDOStatement;
2426

2527
class PostgreSqlConnection extends Connection
@@ -49,6 +51,49 @@ public function bindValues(PDOStatement $statement, array $bindings): void
4951
}
5052
}
5153

54+
/**
55+
* Prepare the query bindings for execution.
56+
*
57+
* Converts booleans to 'true'/'false' when emulated prepares are enabled,
58+
* as PostgreSQL rejects integer literals for boolean columns.
59+
*
60+
* @see https://github.com/laravel/framework/issues/37261
61+
*/
62+
public function prepareBindings(array $bindings): array
63+
{
64+
if (! $this->isUsingEmulatedPrepares()) {
65+
return parent::prepareBindings($bindings);
66+
}
67+
68+
$grammar = $this->getQueryGrammar();
69+
70+
foreach ($bindings as $key => $value) {
71+
if ($value instanceof DateTimeInterface) {
72+
$bindings[$key] = $value->format($grammar->getDateFormat());
73+
} elseif (is_bool($value)) {
74+
$bindings[$key] = $value ? 'true' : 'false';
75+
}
76+
}
77+
78+
return $bindings;
79+
}
80+
81+
/**
82+
* Escape a boolean value for safe SQL embedding.
83+
*/
84+
protected function escapeBool(bool $value): string
85+
{
86+
return $value ? 'true' : 'false';
87+
}
88+
89+
/**
90+
* Determine if the connection is using emulated prepares.
91+
*/
92+
protected function isUsingEmulatedPrepares(): bool
93+
{
94+
return ($this->config['options'][PDO::ATTR_EMULATE_PREPARES] ?? false) === true;
95+
}
96+
5297
/**
5398
* Determine if the given database exception was caused by a unique constraint violation.
5499
*

src/PostgreSqlSwooleExtConnection.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,14 @@ protected function getDoctrineDriver(): PostgresDriver
228228
return new PostgresDriver();
229229
}
230230

231+
/**
232+
* Escape a boolean value for safe SQL embedding.
233+
*/
234+
protected function escapeBool(bool $value): string
235+
{
236+
return $value ? 'true' : 'false';
237+
}
238+
231239
protected function prepare(string $query, bool $useReadPdo = true): PostgreSQLStatement
232240
{
233241
$num = 1;

src/Query/Grammars/PostgresGrammar.php

Lines changed: 67 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,37 @@ protected function compileJsonOverlaps(string $column, string $value): string
345345
return 'EXISTS ( SELECT 1 FROM jsonb_array_elements((' . $column . ')::jsonb) AS elem1, jsonb_array_elements(' . $value . '::jsonb) AS elem2 WHERE elem1 = elem2)';
346346
}
347347

348+
/**
349+
* Compile a "JSON contains key" statement into SQL.
350+
*/
351+
protected function compileJsonContainsKey(string $column): string
352+
{
353+
$segments = explode('->', $column);
354+
355+
$lastSegment = array_pop($segments);
356+
357+
if (filter_var($lastSegment, FILTER_VALIDATE_INT) !== false) {
358+
$i = (int) $lastSegment;
359+
} elseif (preg_match('/\[(-?[0-9]+)\]$/', $lastSegment, $matches)) {
360+
$segments[] = Str::beforeLast($lastSegment, $matches[0]);
361+
362+
$i = (int) $matches[1];
363+
}
364+
365+
$column = str_replace('->>', '->', $this->wrap(implode('->', $segments)));
366+
367+
if (isset($i)) {
368+
return vsprintf('case when %s then %s else false end', [
369+
'jsonb_typeof((' . $column . ')::jsonb) = \'array\'',
370+
'jsonb_array_length((' . $column . ')::jsonb) >= ' . ($i < 0 ? abs($i) : $i + 1),
371+
]);
372+
}
373+
374+
$key = "'" . str_replace("'", "''", $lastSegment) . "'";
375+
376+
return 'coalesce((' . $column . ')::jsonb ?? ' . $key . ', false)';
377+
}
378+
348379
/**
349380
* Compile a "JSON length" statement into SQL.
350381
*
@@ -402,7 +433,7 @@ protected function compileJsonUpdateColumn($key, $value)
402433

403434
$field = $this->wrap(array_shift($segments));
404435

405-
$path = '\'{"' . implode('","', $segments) . '"}\'';
436+
$path = "'{" . implode(',', $this->wrapJsonPathAttributes($segments, '"')) . "}'";
406437

407438
return "{$field} = jsonb_set({$field}::jsonb, {$path}, {$this->parameter($value)})";
408439
}
@@ -540,18 +571,47 @@ protected function wrapJsonBooleanValue($value)
540571
}
541572

542573
/**
543-
* Wrap the attributes of the give JSON path.
574+
* Wrap the attributes of the given JSON path.
544575
*
545576
* @param array $path
546577
* @return array
547578
*/
548579
protected function wrapJsonPathAttributes($path)
549580
{
550-
return array_map(function ($attribute) {
551-
return filter_var($attribute, FILTER_VALIDATE_INT) !== false
552-
? $attribute
553-
: "'{$attribute}'";
554-
}, $path);
581+
$quote = func_num_args() === 2 ? func_get_arg(1) : "'";
582+
583+
return collect($path)
584+
->map(fn ($attribute) => $this->parseJsonPathArrayKeys($attribute))
585+
->collapse()
586+
->map(function ($attribute) use ($quote) {
587+
return filter_var($attribute, FILTER_VALIDATE_INT) !== false
588+
? $attribute
589+
: $quote . $attribute . $quote;
590+
})
591+
->all();
592+
}
593+
594+
/**
595+
* Parse the given JSON path attribute for array keys.
596+
*
597+
* @param string $attribute
598+
* @return array
599+
*/
600+
protected function parseJsonPathArrayKeys($attribute)
601+
{
602+
if (preg_match('/(\[[^\]]+\])+$/', $attribute, $parts)) {
603+
$key = Str::beforeLast($attribute, $parts[0]);
604+
605+
preg_match_all('/\[([^\]]+)\]/', $parts[0], $keys);
606+
607+
return collect([$key])
608+
->merge($keys[1])
609+
->diff('')
610+
->values()
611+
->all();
612+
}
613+
614+
return [$attribute];
555615
}
556616

557617
/**
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
/**
5+
* This file is part of Hyperf.
6+
*
7+
* @link https://www.hyperf.io
8+
* @document https://hyperf.wiki
9+
* @contact group@hyperf.io
10+
* @license https://github.com/hyperf/hyperf/blob/master/LICENSE
11+
*/
12+
13+
namespace HyperfTest\Database\PgSQL\Cases;
14+
15+
use Hyperf\Database\ConnectionInterface;
16+
use Hyperf\Database\PgSQL\Query\Grammars\PostgresGrammar;
17+
use Hyperf\Database\PgSQL\Query\Processors\PostgresProcessor;
18+
use Hyperf\Database\Query\Builder;
19+
use Mockery as m;
20+
use PHPUnit\Framework\Attributes\CoversNothing;
21+
use PHPUnit\Framework\TestCase;
22+
23+
/**
24+
* @internal
25+
* @coversNothing
26+
*/
27+
#[CoversNothing]
28+
class DatabasePostgresQueryBuilderTest extends TestCase
29+
{
30+
protected function tearDown(): void
31+
{
32+
m::close();
33+
}
34+
35+
public function testWhereJsonContainsKey(): void
36+
{
37+
$builder = $this->getBuilder();
38+
$builder->select('*')->from('users')->whereJsonContainsKey('users.options->languages');
39+
$this->assertSame('select * from "users" where coalesce(("users"."options")::jsonb ?? \'languages\', false)', $builder->toSql());
40+
41+
$builder = $this->getBuilder();
42+
$builder->select('*')->from('users')->whereJsonContainsKey('options->language->primary');
43+
$this->assertSame('select * from "users" where coalesce(("options"->\'language\')::jsonb ?? \'primary\', false)', $builder->toSql());
44+
45+
$builder = $this->getBuilder();
46+
$builder->select('*')->from('users')->where('id', '=', 1)->orWhereJsonContainsKey('options->languages');
47+
$this->assertSame('select * from "users" where "id" = ? or coalesce(("options")::jsonb ?? \'languages\', false)', $builder->toSql());
48+
49+
$builder = $this->getBuilder();
50+
$builder->select('*')->from('users')->whereJsonContainsKey('options->languages[0][1]');
51+
$this->assertSame('select * from "users" where case when jsonb_typeof(("options"->\'languages\'->0)::jsonb) = \'array\' then jsonb_array_length(("options"->\'languages\'->0)::jsonb) >= 2 else false end', $builder->toSql());
52+
53+
$builder = $this->getBuilder();
54+
$builder->select('*')->from('users')->whereJsonContainsKey('options->languages[-1]');
55+
$this->assertSame('select * from "users" where case when jsonb_typeof(("options"->\'languages\')::jsonb) = \'array\' then jsonb_array_length(("options"->\'languages\')::jsonb) >= 1 else false end', $builder->toSql());
56+
}
57+
58+
public function testWhereJsonDoesntContainKey(): void
59+
{
60+
$builder = $this->getBuilder();
61+
$builder->select('*')->from('users')->whereJsonDoesntContainKey('options->languages');
62+
$this->assertSame('select * from "users" where not coalesce(("options")::jsonb ?? \'languages\', false)', $builder->toSql());
63+
64+
$builder = $this->getBuilder();
65+
$builder->select('*')->from('users')->where('id', '=', 1)->orWhereJsonDoesntContainKey('options->languages');
66+
$this->assertSame('select * from "users" where "id" = ? or not coalesce(("options")::jsonb ?? \'languages\', false)', $builder->toSql());
67+
68+
$builder = $this->getBuilder();
69+
$builder->select('*')->from('users')->whereJsonDoesntContainKey('options->languages[0][1]');
70+
$this->assertSame('select * from "users" where not case when jsonb_typeof(("options"->\'languages\'->0)::jsonb) = \'array\' then jsonb_array_length(("options"->\'languages\'->0)::jsonb) >= 2 else false end', $builder->toSql());
71+
72+
$builder = $this->getBuilder();
73+
$builder->select('*')->from('users')->whereJsonDoesntContainKey('options->languages[-1]');
74+
$this->assertSame('select * from "users" where not case when jsonb_typeof(("options"->\'languages\')::jsonb) = \'array\' then jsonb_array_length(("options"->\'languages\')::jsonb) >= 1 else false end', $builder->toSql());
75+
}
76+
77+
public function testPostgresUpdateWrappingJsonPathArrayIndex(): void
78+
{
79+
$connection = m::mock(ConnectionInterface::class);
80+
$connection->shouldReceive('update')
81+
->once()
82+
->with('update "users" set "options" = jsonb_set("options"::jsonb, \'{1,"2fa"}\', ?), "meta" = jsonb_set("meta"::jsonb, \'{"tags",0,2}\', ?) where ("options"->1->\'2fa\')::jsonb = \'true\'::jsonb', [
83+
'false',
84+
'"large"',
85+
])
86+
->andReturn(1);
87+
88+
$builder = new Builder($connection, new PostgresGrammar(), new PostgresProcessor());
89+
$result = $builder->from('users')->where('options->[1]->2fa', true)->update([
90+
'options->[1]->2fa' => false,
91+
'meta->tags[0][2]' => 'large',
92+
]);
93+
94+
$this->assertEquals(1, $result);
95+
}
96+
97+
protected function getBuilder(): Builder
98+
{
99+
return new Builder(m::mock(ConnectionInterface::class), new PostgresGrammar(), new PostgresProcessor());
100+
}
101+
}

tests/Cases/SchemaBuilderTest.php renamed to tests/Cases/DatabasePostgresSchemaBuilderTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
* @coversNothing
2727
*/
2828
#[RequiresPhpExtension('swoole', '< 6.0')]
29-
class SchemaBuilderTest extends TestCase
29+
class DatabasePostgresSchemaBuilderTest extends TestCase
3030
{
3131
protected function setUp(): void
3232
{

tests/Cases/DatabasePostgresSchemaGrammarTest.php

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -904,6 +904,66 @@ public function testCompileForeign()
904904
$this->assertSame('alter table "users" add constraint "users_parent_id_foreign" foreign key ("parent_id") references "parents" ("id") on delete cascade deferrable not valid', $statements[0]);
905905
}
906906

907+
public function testCompileForeignWithCascadeOnDelete()
908+
{
909+
$blueprint = new Blueprint('users');
910+
$blueprint->foreign('foo_id')->references('id')->on('orders')->cascadeOnDelete();
911+
$statements = $blueprint->toSql($this->getConnection(), $this->getGrammar());
912+
913+
$this->assertCount(1, $statements);
914+
$this->assertSame('alter table "users" add constraint "users_foo_id_foreign" foreign key ("foo_id") references "orders" ("id") on delete cascade', $statements[0]);
915+
}
916+
917+
public function testCompileForeignWithRestrictOnDelete()
918+
{
919+
$blueprint = new Blueprint('users');
920+
$blueprint->foreign('foo_id')->references('id')->on('orders')->restrictOnDelete();
921+
$statements = $blueprint->toSql($this->getConnection(), $this->getGrammar());
922+
923+
$this->assertCount(1, $statements);
924+
$this->assertSame('alter table "users" add constraint "users_foo_id_foreign" foreign key ("foo_id") references "orders" ("id") on delete restrict', $statements[0]);
925+
}
926+
927+
public function testCompileForeignWithNoActionOnDelete()
928+
{
929+
$blueprint = new Blueprint('users');
930+
$blueprint->foreign('foo_id')->references('id')->on('orders')->noActionOnDelete();
931+
$statements = $blueprint->toSql($this->getConnection(), $this->getGrammar());
932+
933+
$this->assertCount(1, $statements);
934+
$this->assertSame('alter table "users" add constraint "users_foo_id_foreign" foreign key ("foo_id") references "orders" ("id") on delete no action', $statements[0]);
935+
}
936+
937+
public function testCompileForeignWithRestrictOnUpdate()
938+
{
939+
$blueprint = new Blueprint('users');
940+
$blueprint->foreign('foo_id')->references('id')->on('orders')->restrictOnUpdate();
941+
$statements = $blueprint->toSql($this->getConnection(), $this->getGrammar());
942+
943+
$this->assertCount(1, $statements);
944+
$this->assertSame('alter table "users" add constraint "users_foo_id_foreign" foreign key ("foo_id") references "orders" ("id") on update restrict', $statements[0]);
945+
}
946+
947+
public function testCompileForeignWithNullOnUpdate()
948+
{
949+
$blueprint = new Blueprint('users');
950+
$blueprint->foreign('foo_id')->references('id')->on('orders')->nullOnUpdate();
951+
$statements = $blueprint->toSql($this->getConnection(), $this->getGrammar());
952+
953+
$this->assertCount(1, $statements);
954+
$this->assertSame('alter table "users" add constraint "users_foo_id_foreign" foreign key ("foo_id") references "orders" ("id") on update set null', $statements[0]);
955+
}
956+
957+
public function testCompileForeignWithNoActionOnUpdate()
958+
{
959+
$blueprint = new Blueprint('users');
960+
$blueprint->foreign('foo_id')->references('id')->on('orders')->noActionOnUpdate();
961+
$statements = $blueprint->toSql($this->getConnection(), $this->getGrammar());
962+
963+
$this->assertCount(1, $statements);
964+
$this->assertSame('alter table "users" add constraint "users_foo_id_foreign" foreign key ("foo_id") references "orders" ("id") on update no action', $statements[0]);
965+
}
966+
907967
public function testAddingGeometry()
908968
{
909969
$blueprint = new Blueprint('geo');

0 commit comments

Comments
 (0)