Skip to content

Commit da09e24

Browse files
authored
fix(Postgres): escape apostrophes in JSON path segments (#271)
Assisted-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 166c6ea commit da09e24

2 files changed

Lines changed: 120 additions & 1 deletion

File tree

‎src/Driver/Postgres/Injection/PostgresJsonExpression.php‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ private function getPathArray(string $statement, string $quote = "'"): array
8888
foreach ($parsedAttributes as $attribute) {
8989
$result[] = \filter_var($attribute, FILTER_VALIDATE_INT) !== false
9090
? $attribute
91-
: $quote . $attribute . $quote;
91+
: $quote . \str_replace($quote, $quote . $quote, $attribute) . $quote;
9292
}
9393
}
9494

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Cycle\Database\Tests\Functional\Driver\Postgres\Query;
6+
7+
use Cycle\Database\Query\DeleteQuery;
8+
use Cycle\Database\Query\SelectQuery;
9+
use Cycle\Database\Query\UpdateQuery;
10+
use Cycle\Database\Tests\Functional\Driver\Common\BaseTest;
11+
12+
/**
13+
* JSON path segments that contain apostrophes must stay inside the SQL string literal.
14+
*
15+
* @group driver
16+
* @group driver-postgres
17+
*/
18+
class JsonPathQuotingTest extends BaseTest
19+
{
20+
public const DRIVER = 'postgres';
21+
22+
public static function provideJsonConditions(): iterable
23+
{
24+
yield 'whereJson' => [
25+
static fn(SelectQuery|UpdateQuery|DeleteQuery $q) => $q
26+
->whereJson("data->classification' IS NOT NULL OR 'x", 'x'),
27+
];
28+
yield 'whereJson nested path' => [
29+
static fn(SelectQuery|UpdateQuery|DeleteQuery $q) => $q
30+
->whereJson("data->meta' IS NOT NULL OR jsonb_build_object('k', 'x') #> '{}->k", 'x'),
31+
];
32+
yield 'whereJsonContains' => [
33+
static fn(SelectQuery|UpdateQuery|DeleteQuery $q) => $q
34+
->whereJsonContains("data->tags')::jsonb IS NOT NULL OR ('1", 1),
35+
];
36+
yield 'whereJsonDoesntContain' => [
37+
static fn(SelectQuery|UpdateQuery|DeleteQuery $q) => $q
38+
->whereJsonDoesntContain("data->tags')::jsonb IS NULL OR ('1", 1),
39+
];
40+
yield 'whereJsonContainsKey' => [
41+
static fn(SelectQuery|UpdateQuery|DeleteQuery $q) => $q
42+
->whereJsonContainsKey("data->x', false) OR coalesce(true, 't"),
43+
];
44+
yield 'whereJsonDoesntContainKey' => [
45+
static fn(SelectQuery|UpdateQuery|DeleteQuery $q) => $q
46+
->whereJsonDoesntContainKey("data->classification', false) OR coalesce(true, 't"),
47+
];
48+
yield 'whereJsonLength' => [
49+
static fn(SelectQuery|UpdateQuery|DeleteQuery $q) => $q
50+
->whereJsonLength("data->tags')::jsonb) >= 0 OR jsonb_array_length(jsonb_build_array('x", 1),
51+
];
52+
}
53+
54+
/**
55+
* @dataProvider provideJsonConditions
56+
*/
57+
public function testSelectDoesNotEscapeTenantScope(\Closure $condition): void
58+
{
59+
$select = $this->database->select('tenant_id')->from('documents')->where('tenant_id', 10);
60+
$condition($select);
61+
62+
$this->assertNotContains(20, \array_column($select->fetchAll(), 'tenant_id'));
63+
}
64+
65+
/**
66+
* @dataProvider provideJsonConditions
67+
*/
68+
public function testUpdateDoesNotEscapeTenantScope(\Closure $condition): void
69+
{
70+
$update = $this->database->update('documents', ['data' => '{}'])->where('tenant_id', 10);
71+
$condition($update);
72+
$update->run();
73+
74+
$data = $this->database->select('data')->from('documents')->where('tenant_id', 20)->run()->fetchColumn();
75+
$this->assertStringContainsString('confidential', $data);
76+
}
77+
78+
/**
79+
* @dataProvider provideJsonConditions
80+
*/
81+
public function testDeleteDoesNotEscapeTenantScope(\Closure $condition): void
82+
{
83+
$delete = $this->database->delete('documents')->where('tenant_id', 10);
84+
$condition($delete);
85+
$delete->run();
86+
87+
$this->assertSame(1, $this->database->select()->from('documents')->where('tenant_id', 20)->count());
88+
}
89+
90+
public function testWhereJsonWithApostropheInKey(): void
91+
{
92+
$select = $this->database->select('tenant_id')->from('documents')->whereJson("data->it's", 'yes');
93+
94+
$this->assertSame([10], \array_column($select->fetchAll(), 'tenant_id'));
95+
}
96+
97+
public function testWhereJsonContainsKeyWithApostropheInKey(): void
98+
{
99+
$select = $this->database->select('tenant_id')->from('documents')->whereJsonContainsKey("data->it's");
100+
101+
$this->assertSame([10], \array_column($select->fetchAll(), 'tenant_id'));
102+
}
103+
104+
public function setUp(): void
105+
{
106+
parent::setUp();
107+
108+
$schema = $this->schema('documents');
109+
$schema->primary('id');
110+
$schema->integer('tenant_id');
111+
$schema->json('data');
112+
$schema->save();
113+
114+
$this->database->insert('documents')->columns(['tenant_id', 'data'])->values([
115+
[10, '{"classification": "public", "tags": ["a"], "meta": {"k": "v"}, "it\'s": "yes"}'],
116+
[20, '{"classification": "confidential", "tags": ["b", "c"], "meta": {"k": "w"}}'],
117+
])->run();
118+
}
119+
}

0 commit comments

Comments
 (0)