Skip to content

Commit a0acf7c

Browse files
committed
Implement PgsqlDriver (pgsql extension)
1 parent 2dc777e commit a0acf7c

File tree

7 files changed

+326
-3
lines changed

7 files changed

+326
-3
lines changed

.docker/php/Dockerfile

+1-1
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ apt install --yes git sqlite3 libzip-dev libpq-dev libsqlite3-mod-spatialite
5757
rm -rf /var/lib/apt/lists/*
5858
EOF
5959

60-
RUN docker-php-ext-install opcache zip pdo pdo_pgsql pdo_mysql
60+
RUN docker-php-ext-install opcache zip pdo pdo_pgsql pdo_mysql pgsql
6161

6262
# SQLite3 configuration
6363
RUN echo "[sqlite3]\nsqlite3.extension_dir = /usr/lib/x86_64-linux-gnu"> /usr/local/etc/php/conf.d/sqlite3.ini

.github/workflows/ci.yml

+47
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,53 @@ jobs:
270270
env:
271271
COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
272272

273+
phpunit-postgis-pgsql:
274+
name: PHPUnit PostGIS / pgsql
275+
runs-on: ubuntu-22.04
276+
277+
strategy:
278+
matrix:
279+
php-version:
280+
- "8.2"
281+
282+
services:
283+
postgis:
284+
image: "postgis/postgis:17-3.5-alpine"
285+
env:
286+
POSTGRES_USER: postgres
287+
POSTGRES_PASSWORD: postgres
288+
ports:
289+
- "5432:5432"
290+
291+
steps:
292+
- name: Checkout
293+
uses: actions/checkout@v4
294+
295+
- name: Setup PHP
296+
uses: shivammathur/setup-php@v2
297+
with:
298+
php-version: ${{ matrix.php-version }}
299+
extensions: pgsql
300+
coverage: xdebug
301+
302+
- name: Install composer dependencies
303+
uses: ramsey/composer-install@v3
304+
305+
- name: Run PHPUnit with coverage
306+
run: |
307+
mkdir -p build/logs
308+
vendor/bin/phpunit --coverage-clover build/logs/clover.xml
309+
env:
310+
ENGINE: postgis_pgsql
311+
POSTGRES_HOST: 127.0.0.1
312+
POSTGRES_USER: postgres
313+
POSTGRES_PASSWORD: postgres
314+
315+
- name: Upload coverage report to Coveralls
316+
run: vendor/bin/php-coveralls --coverage_clover=build/logs/clover.xml -v
317+
env:
318+
COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
319+
273320
phpunit-sqlite:
274321
name: PHPUnit SpatiaLite / SQLite3
275322
runs-on: ubuntu-22.04

composer.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
},
1616
"require-dev": {
1717
"ext-pdo": "*",
18-
"ext-json": "*",
18+
"ext-pgsql": "*",
1919
"ext-sqlite3": "*",
2020
"brick/reflection": "~0.5.0",
2121
"phpunit/phpunit": "^11.0",

phpunit-bootstrap.php

+32-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
use Brick\Geo\Engine\Database\Driver\PdoMysqlDriver;
44
use Brick\Geo\Engine\Database\Driver\PdoPgsqlDriver;
5+
use Brick\Geo\Engine\Database\Driver\PgsqlDriver;
56
use Brick\Geo\Engine\Database\Driver\Sqlite3Driver;
67
use Brick\Geo\Engine\GeosEngine;
78
use Brick\Geo\Engine\GeosOpEngine;
@@ -45,7 +46,7 @@ function getRequiredEnv(string $name): string
4546
echo 'WARNING: running tests without a geometry engine.', PHP_EOL;
4647
echo 'All tests requiring a geometry engine will be skipped.', PHP_EOL;
4748
echo 'To run tests with a geometry engine, use: ENGINE={engine} vendor/bin/phpunit', PHP_EOL;
48-
echo 'Available engines: geos, geosop, mysql_pdo, mariadb_pdo, postgis_pdo, spatialite_sqlite3', PHP_EOL;
49+
echo 'Available engines: geos, geosop, mysql_pdo, mariadb_pdo, postgis_pdo, postgis_pgsql, spatialite_sqlite3', PHP_EOL;
4950
} else {
5051
$driver = null;
5152

@@ -149,6 +150,36 @@ function getRequiredEnv(string $name): string
149150

150151
break;
151152

153+
case 'postgis_pgsql':
154+
echo 'Using PostgisEngine with PgsqlDriver', PHP_EOL;
155+
156+
$host = getRequiredEnv('POSTGRES_HOST');
157+
$port = getOptionalEnvOrDefault('POSTGRES_PORT', '5432');
158+
$username = getRequiredEnv('POSTGRES_USER');
159+
$password = getRequiredEnv('POSTGRES_PASSWORD');
160+
161+
$connection = pg_connect(sprintf(
162+
'host=%s port=%d user=%s password=%s',
163+
$host,
164+
$port,
165+
$username,
166+
$password,
167+
));
168+
169+
$driver = new PgsqlDriver($connection);
170+
$engine = new PostgisEngine($driver);
171+
172+
$version = $driver->executeQuery('SELECT version()')->get(0)->asString();
173+
echo 'PostgreSQL version: ', $version, PHP_EOL;
174+
175+
$version = $driver->executeQuery('SELECT PostGIS_Version()')->get(0)->asString();
176+
echo 'PostGIS version: ', $version, PHP_EOL;
177+
178+
$version = $driver->executeQuery('SELECT PostGIS_GEOS_Version()')->get(0)->asString();
179+
echo 'PostGIS GEOS version: ', $version, PHP_EOL;
180+
181+
break;
182+
152183
case 'spatialite_sqlite3':
153184
echo 'Using SpatialiteEngine with Sqlite3Driver', PHP_EOL;
154185

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Brick\Geo\Engine\Database\Driver\Internal;
6+
7+
use Brick\Geo\Exception\GeometryEngineException;
8+
9+
final class TypeConverter
10+
{
11+
/**
12+
* Safely converts a string to integer, with no value loss.
13+
*
14+
* @throws GeometryEngineException
15+
*/
16+
public static function convertStringToInt(string $value) : int
17+
{
18+
$intValue = (int) $value;
19+
20+
if ($value === (string) $intValue) {
21+
return $intValue;
22+
}
23+
24+
if ($value === '-0' || preg_match('/^-?[0-9]+$/', $value) !== 1) {
25+
throw new GeometryEngineException(sprintf(
26+
'The database returned an unexpected type: expected integer string, got %s.',
27+
var_export($value, true),
28+
));
29+
}
30+
31+
throw new GeometryEngineException('The database return an out of range integer: ' . $value);
32+
}
33+
}
+149
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Brick\Geo\Engine\Database\Driver;
6+
7+
use Brick\Geo\Engine\Database\Driver\Internal\TypeConverter;
8+
use Brick\Geo\Engine\Database\Query\BinaryValue;
9+
use Brick\Geo\Engine\Database\Query\ScalarValue;
10+
use Brick\Geo\Engine\Database\Result\Row;
11+
use Brick\Geo\Exception\GeometryEngineException;
12+
use Override;
13+
use PgSql\Connection;
14+
15+
/**
16+
* Database driver using the pgsql extension for PostgreSQL.
17+
*/
18+
final class PgsqlDriver implements DatabaseDriver
19+
{
20+
public function __construct(
21+
private readonly Connection $connection,
22+
) {
23+
}
24+
25+
#[Override]
26+
public function executeQuery(string|BinaryValue|ScalarValue ...$query) : Row
27+
{
28+
$position = 1;
29+
30+
$queryString = '';
31+
$queryParams = [];
32+
33+
foreach ($query as $queryPart) {
34+
if ($queryPart instanceof BinaryValue) {
35+
$queryString .= '$' . $position++ . '::bytea';
36+
/** @var string */
37+
$queryParams[] = pg_escape_bytea($this->connection, $queryPart->value);
38+
} elseif ($queryPart instanceof ScalarValue) {
39+
$queryString .= '$' . $position++;
40+
41+
if (is_int($queryPart->value)) {
42+
$queryString .= '::int';
43+
$queryParams[] = $queryPart->value;
44+
} elseif (is_float($queryPart->value)) {
45+
$queryString .= '::float';
46+
$queryParams[] = $queryPart->value;
47+
} elseif (is_bool($queryPart->value)) {
48+
$queryString .= '::bool';
49+
$queryParams[] = $queryPart->value ? 't' : 'f';
50+
} else {
51+
$queryParams[] = $queryPart->value;
52+
}
53+
} else {
54+
$queryString .= $queryPart;
55+
}
56+
}
57+
58+
// Mute warnings and back up the current error reporting level.
59+
$errorReportingLevel = error_reporting(0);
60+
61+
try {
62+
$value = pg_prepare($this->connection, '', $queryString);
63+
64+
if ($value === false) {
65+
$this->throwLastError();
66+
}
67+
68+
$result = pg_execute($this->connection, '', $queryParams);
69+
70+
if ($result === false) {
71+
$this->throwLastError();
72+
}
73+
74+
/** @var list<list<mixed>>|false $rows */
75+
$rows = pg_fetch_all($result, PGSQL_NUM);
76+
77+
if ($rows === false) {
78+
$this->throwLastError();
79+
}
80+
81+
if (count($rows) !== 1) {
82+
throw new GeometryEngineException(sprintf('Expected exactly one row, got %d.', count($rows)));
83+
}
84+
} finally {
85+
// Restore the error reporting level.
86+
error_reporting($errorReportingLevel);
87+
}
88+
89+
return new Row($this, $rows[0]);
90+
}
91+
92+
/**
93+
* @throws GeometryEngineException
94+
*/
95+
private function throwLastError() : never
96+
{
97+
throw new GeometryEngineException('The engine return an error: ' . pg_last_error($this->connection));
98+
}
99+
100+
#[Override]
101+
public function convertBinaryResult(mixed $value) : string
102+
{
103+
if (is_string($value)) {
104+
return pg_unescape_bytea($value);
105+
}
106+
107+
throw GeometryEngineException::unexpectedDatabaseReturnType('string', $value);
108+
}
109+
110+
#[Override]
111+
public function convertStringResult(mixed $value) : string
112+
{
113+
if (is_string($value)) {
114+
return $value;
115+
}
116+
117+
throw GeometryEngineException::unexpectedDatabaseReturnType('string', $value);
118+
}
119+
120+
#[Override]
121+
public function convertIntResult(mixed $value) : int
122+
{
123+
if (is_string($value)) {
124+
return TypeConverter::convertStringToInt($value);
125+
}
126+
127+
throw GeometryEngineException::unexpectedDatabaseReturnType('integer string', $value);
128+
}
129+
130+
#[Override]
131+
public function convertFloatResult(mixed $value) : float
132+
{
133+
if (is_string($value) && is_numeric($value)) {
134+
return (float) $value;
135+
}
136+
137+
throw GeometryEngineException::unexpectedDatabaseReturnType('number or numeric string', $value);
138+
}
139+
140+
#[Override]
141+
public function convertBoolResult(mixed $value) : bool
142+
{
143+
return match ($value) {
144+
't' => true,
145+
'f' => false,
146+
default => throw GeometryEngineException::unexpectedDatabaseReturnType('t or f', $value),
147+
};
148+
}
149+
}

tests/Engine/TypeConverterTest.php

+63
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<?php
2+
3+
4+
declare(strict_types=1);
5+
6+
namespace Brick\Geo\Tests\Engine;
7+
8+
use Brick\Geo\Engine\Database\Driver\Internal\TypeConverter;
9+
use Brick\Geo\Exception\GeometryEngineException;
10+
use PHPUnit\Framework\Attributes\DataProvider;
11+
use PHPUnit\Framework\TestCase;
12+
13+
/**
14+
* Unit tests for TypeConverter.
15+
*/
16+
class TypeConverterTest extends TestCase
17+
{
18+
#[DataProvider('providerConvertStringToInt')]
19+
public function testConvertStringToInt(string $string, int $expected) : void
20+
{
21+
$actual = TypeConverter::convertStringToInt($string);
22+
self::assertSame($expected, $actual);
23+
}
24+
25+
public static function providerConvertStringToInt() : array
26+
{
27+
return [
28+
[(string) PHP_INT_MIN, PHP_INT_MIN],
29+
['-123456789', -123456789],
30+
['-123', -123],
31+
['-12', -12],
32+
['-1', -1],
33+
['0', 0],
34+
['1', 1],
35+
['12', 12],
36+
['123', 123],
37+
['123456789', 123456789],
38+
[(string) PHP_INT_MAX, PHP_INT_MAX],
39+
];
40+
}
41+
#[DataProvider('providerConvertStringToIntThrowsException')]
42+
public function testConvertStringToIntThrowsException(string $string, string $expectedMessage) : void
43+
{
44+
$this->expectException(GeometryEngineException::class);
45+
$this->expectExceptionMessage($expectedMessage);
46+
47+
TypeConverter::convertStringToInt($string);
48+
}
49+
50+
public static function providerConvertStringToIntThrowsException() : array
51+
{
52+
return [
53+
['', "The database returned an unexpected type: expected integer string, got ''."],
54+
['foo', "The database returned an unexpected type: expected integer string, got 'foo'."],
55+
['-0', "The database returned an unexpected type: expected integer string, got '-0'."],
56+
['1.0', "The database returned an unexpected type: expected integer string, got '1.0'."],
57+
['123 ', "The database returned an unexpected type: expected integer string, got '123 '."],
58+
[' 123', "The database returned an unexpected type: expected integer string, got ' 123'."],
59+
[' 123 ', "The database returned an unexpected type: expected integer string, got ' 123 '."],
60+
['123456789012345678901234567890', 'The database return an out of range integer: 123456789012345678901234567890'],
61+
];
62+
}
63+
}

0 commit comments

Comments
 (0)