Skip to content

Commit e4282d3

Browse files
[Fix] Resolve postgresql collation issue (#1162)
* wip * fix * fixes * null check and remove 14 * fixes
1 parent e676018 commit e4282d3

9 files changed

Lines changed: 204 additions & 23 deletions

File tree

app/Actions/Database/CreateDatabase.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,12 @@ private function validate(Server $server, array $input): void
6767
'charset' => [
6868
'required',
6969
'string',
70+
'regex:/^[A-Za-z0-9_]+$/',
7071
],
7172
'collation' => [
7273
'required',
7374
'string',
75+
'regex:/^[A-Za-z0-9._@-]+$/',
7476
],
7577
'username' => [
7678
'nullable',

app/Providers/ServiceTypeServiceProvider.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,6 @@ private function databases(): void
8989
'17',
9090
'16',
9191
'15',
92-
'14',
9392
])
9493
->configPaths([
9594
[

resources/js/components/ui/combobox.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,15 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
99
export function Combobox({
1010
items,
1111
value,
12+
id,
1213
searchText = 'Search items...',
1314
noneFoundText = 'No items found.',
1415
placeholder = '',
1516
onValueChange,
1617
}: {
1718
items: { value: string; label: string }[];
1819
value: string;
20+
id?: string;
1921
searchText?: string;
2022
noneFoundText?: string;
2123
placeholder?: string;
@@ -27,7 +29,7 @@ export function Combobox({
2729
return (
2830
<Popover open={open} onOpenChange={setOpen}>
2931
<PopoverTrigger asChild>
30-
<Button variant="outline" role="combobox" aria-expanded={open} className="flex-1 justify-between">
32+
<Button id={id} variant="outline" role="combobox" aria-expanded={open} className="flex-1 justify-between">
3133
<span className={selectedLabel ? '' : 'text-muted-foreground'}>{selectedLabel || placeholder}</span>
3234
<ChevronsUpDown className="opacity-50" />
3335
</Button>

resources/js/pages/databases/components/create-database.tsx

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { Label } from '@/components/ui/label';
1717
import { Input } from '@/components/ui/input';
1818
import InputError from '@/components/ui/input-error';
1919
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
20+
import { Combobox } from '@/components/ui/combobox';
2021
import axios from 'axios';
2122
import { Checkbox } from '@/components/ui/checkbox';
2223
import DatabaseUserSelect from '@/pages/database-users/components/database-user-select';
@@ -158,18 +159,15 @@ export default function CreateDatabase({
158159
</FormField>
159160
<FormField>
160161
<Label htmlFor="collation">Collation</Label>
161-
<Select onValueChange={(value) => form.setData('collation', value)} value={form.data.collation}>
162-
<SelectTrigger id="collation">
163-
<SelectValue placeholder="Select collation" />
164-
</SelectTrigger>
165-
<SelectContent>
166-
{collations.map((collation) => (
167-
<SelectItem key={collation} value={collation}>
168-
{collation}
169-
</SelectItem>
170-
))}
171-
</SelectContent>
172-
</Select>
162+
<Combobox
163+
id="collation"
164+
items={collations.map((collation) => ({ value: collation, label: collation }))}
165+
value={form.data.collation}
166+
onValueChange={(value) => form.setData('collation', value)}
167+
placeholder="Select collation"
168+
searchText="Search collations..."
169+
noneFoundText="No collations found."
170+
/>
173171
<InputError message={form.errors.collation} />
174172
</FormField>
175173
<FormField>

resources/views/ssh/services/database/postgresql/create.blade.php

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,42 @@
1-
if ! sudo -u postgres psql -c "CREATE DATABASE \"{{ $name }}\" WITH ENCODING '{{ $charset }}'"; then
1+
if ! sudo -u postgres psql -v ON_ERROR_STOP=1 <<'EOSQL'
2+
DO $$
3+
BEGIN
4+
IF NOT EXISTS (SELECT 1 FROM pg_collation WHERE collname = '{{ $collation }}') THEN
5+
RAISE EXCEPTION 'collation "%" does not exist', '{{ $collation }}';
6+
END IF;
7+
END $$;
8+
SELECT format(
9+
'CREATE DATABASE %I WITH ENCODING %L TEMPLATE template0 %s',
10+
'{{ $name }}',
11+
'{{ $charset }}',
12+
coalesce((
13+
SELECT CASE
14+
WHEN s.provider = 'i' AND s.icu_supported AND s.locale IS NOT NULL
15+
THEN format('LOCALE_PROVIDER icu ICU_LOCALE %L LC_COLLATE %L LC_CTYPE %L', s.locale, 'C', 'C')
16+
WHEN s.provider = 'b' AND s.builtin_supported AND s.locale IS NOT NULL
17+
THEN format('LOCALE_PROVIDER builtin BUILTIN_LOCALE %L', s.locale)
18+
WHEN s.provider = 'c' AND s.collate IS NOT NULL
19+
THEN format('LC_COLLATE %L LC_CTYPE %L', s.collate, s.ctype)
20+
ELSE ''
21+
END
22+
FROM (
23+
SELECT
24+
to_jsonb(c)->>'collprovider' AS provider,
25+
coalesce(to_jsonb(c)->>'colllocale', to_jsonb(c)->>'colliculocale') AS locale,
26+
to_jsonb(c)->>'collcollate' AS collate,
27+
to_jsonb(c)->>'collctype' AS ctype,
28+
current_setting('server_version_num')::int >= 150000 AS icu_supported,
29+
current_setting('server_version_num')::int >= 170000 AS builtin_supported
30+
FROM pg_collation c
31+
WHERE c.collname = '{{ $collation }}'
32+
ORDER BY c.collencoding DESC
33+
LIMIT 1
34+
) s
35+
), '')
36+
)
37+
\gexec
38+
EOSQL
39+
then
240
echo 'VITO_SSH_ERROR' && exit 1
341
fi
442

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
1-
if ! sudo -u postgres psql -c "SELECT collname as collation,
2-
pg_encoding_to_char(collencoding) as charset,
1+
if ! sudo -u postgres psql -c "SELECT DISTINCT collname as collation,
2+
CASE
3+
WHEN collencoding = -1 THEN current_setting('server_encoding')
4+
ELSE pg_encoding_to_char(collencoding)
5+
END as charset,
36
'' as id,
47
'' as \"default\",
58
'Yes' as compiled,
69
'' as sortlen,
710
'' as pad_attribute
811
FROM pg_collation
9-
WHERE not pg_encoding_to_char(collencoding) = ''
10-
ORDER BY charset;";
12+
WHERE collencoding = -1
13+
OR pg_encoding_to_char(collencoding) <> ''
14+
ORDER BY charset, collation;";
1115
then
1216
echo 'VITO_SSH_ERROR' && exit 1
1317
fi
Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,18 @@
11
if ! sudo -u postgres psql -c "SELECT
2-
datname AS database_name,
3-
pg_encoding_to_char(encoding) AS charset,
4-
datcollate AS collation
5-
FROM pg_database;";
2+
d.datname AS database_name,
3+
pg_encoding_to_char(d.encoding) AS charset,
4+
CASE
5+
WHEN to_jsonb(d)->>'datlocprovider' = 'i'
6+
THEN coalesce(coalesce(to_jsonb(d)->>'datlocale', to_jsonb(d)->>'daticulocale') || '-x-icu', d.datcollate)
7+
WHEN to_jsonb(d)->>'datlocprovider' = 'b'
8+
THEN CASE coalesce(to_jsonb(d)->>'datlocale', to_jsonb(d)->>'daticulocale')
9+
WHEN 'C.UTF-8' THEN 'pg_c_utf8'
10+
WHEN 'PG_UNICODE_FAST' THEN 'pg_unicode_fast'
11+
ELSE coalesce(to_jsonb(d)->>'datlocale', to_jsonb(d)->>'daticulocale')
12+
END
13+
ELSE d.datcollate
14+
END AS collation
15+
FROM pg_database d;";
616
then
717
echo 'VITO_SSH_ERROR' && exit 1
818
fi

tests/Feature/DatabaseTest.php

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@
44

55
use App\Enums\DatabaseStatus;
66
use App\Enums\DatabaseUserStatus;
7+
use App\Enums\ServiceStatus;
78
use App\Facades\SSH;
89
use App\Models\Database;
910
use App\Models\DatabaseUser;
11+
use App\Services\Database\Mysql;
12+
use App\Services\Database\Postgresql;
1013
use Illuminate\Foundation\Testing\RefreshDatabase;
1114
use Inertia\Testing\AssertableInertia;
1215
use Tests\TestCase;
@@ -143,4 +146,99 @@ public function test_sync_databases(): void
143146
$this->patch(route('databases.sync', $this->server))
144147
->assertSessionDoesntHaveErrors();
145148
}
149+
150+
public function test_create_postgresql_database_with_icu_collation(): void
151+
{
152+
$this->actingAs($this->user);
153+
154+
$this->usePostgresql();
155+
156+
SSH::fake();
157+
158+
$this->post(route('databases.store', $this->server), [
159+
'name' => 'pg_database',
160+
'charset' => 'UTF8',
161+
'collation' => 'en-US-x-icu',
162+
])->assertSessionDoesntHaveErrors();
163+
164+
$this->assertDatabaseHas('databases', [
165+
'name' => 'pg_database',
166+
'collation' => 'en-US-x-icu',
167+
'status' => DatabaseStatus::READY,
168+
]);
169+
}
170+
171+
public function test_create_database_rejects_malicious_collation(): void
172+
{
173+
$this->actingAs($this->user);
174+
175+
SSH::fake();
176+
177+
$this->post(route('databases.store', $this->server), [
178+
'name' => 'database',
179+
'charset' => 'utf8mb4',
180+
'collation' => "x'; DROP DATABASE postgres; --",
181+
])->assertSessionHasErrors('collation');
182+
183+
$this->assertDatabaseMissing('databases', [
184+
'name' => 'database',
185+
]);
186+
}
187+
188+
public function test_postgresql_create_script_applies_collation_locale(): void
189+
{
190+
$rendered = view('ssh.services.database.postgresql.create', [
191+
'name' => 'pg_database',
192+
'charset' => 'UTF8',
193+
'collation' => 'en-US-x-icu',
194+
])->render();
195+
196+
$this->assertStringContainsString('CREATE DATABASE', $rendered);
197+
$this->assertStringContainsString('LOCALE_PROVIDER', $rendered);
198+
$this->assertStringContainsString('\gexec', $rendered);
199+
$this->assertStringContainsString('en-US-x-icu', $rendered);
200+
}
201+
202+
public function test_sync_postgresql_preserves_icu_collation(): void
203+
{
204+
$this->actingAs($this->user);
205+
206+
$this->usePostgresql();
207+
208+
Database::factory()->create([
209+
'server_id' => $this->server,
210+
'name' => 'pg_database',
211+
'charset' => 'UTF8',
212+
'collation' => 'af-NA-x-icu',
213+
'status' => DatabaseStatus::READY,
214+
]);
215+
216+
SSH::fake(<<<'EOD'
217+
database_name | charset | collation
218+
---------------+---------+-------------
219+
pg_database | UTF8 | af-NA-x-icu
220+
(1 row)
221+
EOD);
222+
223+
$this->patch(route('databases.sync', $this->server))
224+
->assertSessionDoesntHaveErrors();
225+
226+
$this->assertDatabaseHas('databases', [
227+
'server_id' => $this->server->id,
228+
'name' => 'pg_database',
229+
'collation' => 'af-NA-x-icu',
230+
]);
231+
}
232+
233+
private function usePostgresql(): void
234+
{
235+
$this->server->services()->where('type', Mysql::type())->delete();
236+
$this->server->services()->create([
237+
'type' => Postgresql::type(),
238+
'name' => Postgresql::id(),
239+
'version' => '15',
240+
'status' => ServiceStatus::READY,
241+
]);
242+
$this->server->refresh();
243+
}
146244
}

tests/Unit/SSH/Services/Database/GetCharsetsTest.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,36 @@ public static function data(): array
160160
],
161161
],
162162
],
163+
[
164+
'postgresql',
165+
'18',
166+
<<<'EOD'
167+
collation | charset | id | default | compiled | sortlen | pad_attribute
168+
-------------+---------+----+---------+----------+---------+---------------
169+
C | UTF8 | | | Yes | |
170+
POSIX | UTF8 | | | Yes | |
171+
ucs_basic | UTF8 | | | Yes | |
172+
pg_c_utf8 | UTF8 | | | Yes | |
173+
unicode | UTF8 | | | Yes | |
174+
en-US-x-icu | UTF8 | | | Yes | |
175+
en_US.utf8 | UTF8 | | | Yes | |
176+
(7 rows)
177+
EOD,
178+
[
179+
'UTF8' => [
180+
'default' => null,
181+
'list' => [
182+
'C',
183+
'POSIX',
184+
'ucs_basic',
185+
'pg_c_utf8',
186+
'unicode',
187+
'en-US-x-icu',
188+
'en_US.utf8',
189+
],
190+
],
191+
],
192+
],
163193
];
164194
}
165195
}

0 commit comments

Comments
 (0)