Skip to content

Commit 34415cf

Browse files
authored
Postgresql mappers (#2306)
* chore: skeleton for postgresql valinor bundle * feature: postgresql valinor bridge - type mapper now accepts next RowMapper - improved documentation page * fix: address issues from #2305 review
1 parent 3610bd8 commit 34415cf

46 files changed

Lines changed: 1939 additions & 398 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.codecov.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,3 +180,7 @@ component_management:
180180
name: bridge-phpunit-telemetry
181181
paths:
182182
- src/bridge/phpunit/telemetry/**
183+
- component_id: bridge-postgresql-valinor
184+
name: bridge-postgresql-valinor
185+
paths:
186+
- src/bridge/postgresql/valinor/**

.github/workflows/monorepo-split.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ jobs:
8686
split_repository: 'monolog-telemetry-bridge'
8787
- local_path: 'src/bridge/openapi/specification'
8888
split_repository: 'openapi-specification-bridge'
89+
- local_path: 'src/bridge/postgresql/valinor'
90+
split_repository: 'postgresql-valinor-bridge'
8991
- local_path: 'src/bridge/psr7/telemetry'
9092
split_repository: 'psr7-telemetry-bridge'
9193
- local_path: 'src/bridge/psr18/telemetry'

composer.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"async-aws/s3": "^2.6",
2525
"brick/math": "^0.12 || ^0.13 || ^0.14",
2626
"coduo/php-humanizer": "^5.0",
27+
"cuyz/valinor": "^2.4",
2728
"doctrine/dbal": "^3.6 || ^4.0",
2829
"elasticsearch/elasticsearch": "^7.6|^8.0",
2930
"google/apiclient": "^2.13",
@@ -104,6 +105,7 @@
104105
"flow-php/parquet": "self.version",
105106
"flow-php/parquet-viewer": "self.version",
106107
"flow-php/postgresql": "self.version",
108+
"flow-php/postgresql-valinor-bridge": "self.version",
107109
"flow-php/psr7-telemetry-bridge": "self.version",
108110
"flow-php/psr18-telemetry-bridge": "self.version",
109111
"flow-php/snappy": "self.version",
@@ -144,6 +146,7 @@
144146
"src/bridge/monolog/http/src/Flow",
145147
"src/bridge/monolog/telemetry/src/Flow",
146148
"src/bridge/openapi/specification/src/Flow",
149+
"src/bridge/postgresql/valinor/src/Flow",
147150
"src/bridge/psr7/telemetry/src/Flow",
148151
"src/bridge/psr18/telemetry/src/Flow",
149152
"src/bridge/symfony/filesystem-bundle/src/Flow",
@@ -198,6 +201,7 @@
198201
"src/bridge/monolog/telemetry/src/Flow/Bridge/Monolog/Telemetry/DSL/functions.php",
199202
"src/bridge/phpunit/postgresql/src/Flow/Bridge/PHPUnit/PostgreSQL/DSL/functions.php",
200203
"src/bridge/openapi/specification/src/Flow/Bridge/OpenAPI/Specification/DSL/functions.php",
204+
"src/bridge/postgresql/valinor/src/Flow/PostgreSql/Bridge/Valinor/DSL/functions.php",
201205
"src/bridge/psr7/telemetry/src/Flow/Bridge/Psr7/Telemetry/DSL/functions.php",
202206
"src/bridge/psr18/telemetry/src/Flow/Bridge/Psr18/Telemetry/DSL/functions.php",
203207
"src/bridge/symfony/http-foundation-telemetry/src/Flow/Bridge/Symfony/HttpFoundationTelemetry/DSL/functions.php",
@@ -247,6 +251,7 @@
247251
"src/bridge/monolog/http/tests/Flow",
248252
"src/bridge/monolog/telemetry/tests/Flow",
249253
"src/bridge/openapi/specification/tests/Flow",
254+
"src/bridge/postgresql/valinor/tests/Flow",
250255
"src/bridge/psr7/telemetry/tests/Flow",
251256
"src/bridge/psr18/telemetry/tests/Flow",
252257
"src/bridge/symfony/filesystem-bundle/tests/Flow",
@@ -327,6 +332,7 @@
327332
"@test:bridge:openapi-specification",
328333
"@test:bridge:phpunit-postgresql",
329334
"@test:bridge:phpunit-telemetry",
335+
"@test:bridge:postgresql-valinor",
330336
"@test:bridge:psr7-telemetry",
331337
"@test:bridge:psr18-telemetry",
332338
"@test:bridge:symfony-filesystem-bundle",
@@ -415,6 +421,9 @@
415421
"test:bridge:openapi-specification": [
416422
"tools/phpunit/vendor/bin/phpunit --testsuite=bridge-openapi-specification-unit --log-junit ./var/phpunit/logs/bridge-openapi-specification-unit.junit.xml --coverage-clover=./var/phpunit/coverage/clover/bridge-openapi-specification-unit.coverage.xml"
417423
],
424+
"test:bridge:postgresql-valinor": [
425+
"tools/phpunit/vendor/bin/phpunit --testsuite=bridge-postgresql-valinor-unit --log-junit ./var/phpunit/logs/bridge-postgresql-valinor-unit.junit.xml --coverage-clover=./var/phpunit/coverage/clover/bridge-postgresql-valinor-unit.coverage.xml"
426+
],
418427
"test:bridge:psr7-telemetry": [
419428
"tools/phpunit/vendor/bin/phpunit --testsuite=bridge-psr7-telemetry-unit --log-junit ./var/phpunit/logs/bridge-psr7-telemetry-unit.junit.xml --coverage-clover=./var/phpunit/coverage/clover/bridge-psr7-telemetry-unit.coverage.xml",
420429
"tools/phpunit/vendor/bin/phpunit --testsuite=bridge-psr7-telemetry-integration --log-junit ./var/phpunit/logs/bridge-psr7-telemetry-integration.junit.xml --coverage-clover=./var/phpunit/coverage/clover/bridge-psr7-telemetry-integration.coverage.xml"
@@ -586,6 +595,7 @@
586595
"./tools/phpdocumentor/vendor/bin/phpdoc --config=./phpdoc/bridge.filesystem.azure.xml",
587596
"./tools/phpdocumentor/vendor/bin/phpdoc --config=./phpdoc/bridge.monolog.http.xml",
588597
"./tools/phpdocumentor/vendor/bin/phpdoc --config=./phpdoc/bridge.openapi.specification.xml",
598+
"./tools/phpdocumentor/vendor/bin/phpdoc --config=./phpdoc/bridge.postgresql.valinor.xml",
589599
"./tools/phpdocumentor/vendor/bin/phpdoc --config=./phpdoc/bridge.symfony.http-foundation.xml"
590600
],
591601
"build:parquet:thrift": [

composer.lock

Lines changed: 78 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
# PostgreSQL Valinor Bridge
2+
3+
Bridge that lets [cuyz/valinor](https://valinor.cuyz.io) act as a `RowMapper` for the [flow-php/postgresql](/documentation/components/libs/postgresql.md) client. Use it when you want Valinor's strict, type-safe object hydration to materialize PostgreSQL rows into immutable DTOs or value objects.
4+
5+
- [Back](/documentation/introduction.md)
6+
- [➡️ Installation](/documentation/installation/packages/postgresql-valinor-bridge.md)
7+
- [Packagist](https://packagist.org/packages/flow-php/postgresql-valinor-bridge)
8+
- [GitHub](https://github.com/flow-php/postgresql-valinor-bridge)
9+
- [API Reference](/documentation/api/bridge/postgresql/valinor)
10+
11+
[TOC]
12+
13+
## Basic Usage
14+
15+
### With `MapperBuilder`
16+
17+
```php
18+
<?php
19+
20+
use function Flow\PostgreSql\Bridge\Valinor\DSL\valinor_builder_mapper;
21+
use function Flow\PostgreSql\DSL\{pgsql_client, pgsql_connection};
22+
use CuyZ\Valinor\MapperBuilder;
23+
24+
readonly class User
25+
{
26+
public function __construct(
27+
public int $id,
28+
public string $name,
29+
public string $email,
30+
) {}
31+
}
32+
33+
$client = pgsql_client(pgsql_connection('host=localhost dbname=mydb'));
34+
35+
$user = $client->fetchInto(
36+
valinor_builder_mapper(new MapperBuilder(), User::class),
37+
'SELECT id, name, email FROM users WHERE id = $1',
38+
[1],
39+
);
40+
```
41+
42+
### With a Pre-built `TreeMapper`
43+
44+
When you want to share the same Valinor configuration across many mappers, build the `TreeMapper` once and reuse it:
45+
46+
```php
47+
<?php
48+
49+
use function Flow\PostgreSql\Bridge\Valinor\DSL\valinor_tree_mapper;
50+
use CuyZ\Valinor\MapperBuilder;
51+
52+
$treeMapper = (new MapperBuilder())
53+
->allowSuperfluousKeys()
54+
->mapper();
55+
56+
$users = $client->fetchAllInto(
57+
valinor_tree_mapper($treeMapper, User::class),
58+
'SELECT id, name, email, internal_flag FROM users',
59+
);
60+
```
61+
62+
## Combining With `TypeMapper` for Value Coercion
63+
64+
PostgreSQL drivers return values as PHP scalars or strings — e.g. `JSONB` arrives as a JSON-encoded string, `TIMESTAMP` as `'2026-01-01 14:30:00'`, `UUID` as a string. Valinor is strict about types, so feeding raw row arrays directly often fails.
65+
66+
The cleanest pattern is to chain `TypeMapper` (from flow-php/postgresql) **in front of** the Valinor mapper. `TypeMapper` casts each row column to a concrete type via [flow-php/types](/documentation/components/libs/types.md), and the resulting array is passed to Valinor for object construction.
67+
68+
`type_mapper(...)` accepts an optional `RowMapper $next`; when provided, the cast result is forwarded to it and its return value becomes the final output.
69+
70+
### Decoding `JSONB` Into Nested Objects
71+
72+
```php
73+
<?php
74+
75+
use function Flow\PostgreSql\Bridge\Valinor\DSL\valinor_builder_mapper;
76+
use function Flow\PostgreSql\DSL\{pgsql_client, pgsql_connection, type_mapper};
77+
use function Flow\Types\DSL\{type_integer, type_string, type_structure};
78+
use CuyZ\Valinor\MapperBuilder;
79+
80+
readonly class Address
81+
{
82+
public function __construct(
83+
public string $street,
84+
public string $city,
85+
) {}
86+
}
87+
88+
readonly class User
89+
{
90+
public function __construct(
91+
public int $id,
92+
public string $name,
93+
public Address $address,
94+
) {}
95+
}
96+
97+
$mapper = type_mapper(
98+
type_structure([
99+
'id' => type_integer(),
100+
'name' => type_string(),
101+
'address' => type_structure([
102+
'street' => type_string(),
103+
'city' => type_string(),
104+
]),
105+
]),
106+
valinor_builder_mapper(new MapperBuilder(), User::class),
107+
);
108+
109+
// `address` arrives from PostgreSQL as a JSON string:
110+
// ['id' => 1, 'name' => 'Jane', 'address' => '{"street":"Main 1","city":"Warsaw"}']
111+
//
112+
// TypeMapper decodes it into ['street' => 'Main 1', 'city' => 'Warsaw'] before
113+
// handing the row to Valinor, which then constructs Address and User.
114+
$user = $client->fetchInto($mapper, 'SELECT id, name, address FROM users WHERE id = $1', [1]);
115+
```
116+
117+
### Coercing Date Strings Into `DateTimeImmutable`
118+
119+
PostgreSQL returns `TIMESTAMP` values like `'2026-01-01 14:30:00'`. Valinor cannot construct `\DateTimeImmutable` from a raw string without explicit configuration, but `type_datetime()` can.
120+
121+
```php
122+
<?php
123+
124+
use function Flow\PostgreSql\Bridge\Valinor\DSL\valinor_builder_mapper;
125+
use function Flow\PostgreSql\DSL\type_mapper;
126+
use function Flow\Types\DSL\{type_datetime, type_integer, type_string, type_structure};
127+
use CuyZ\Valinor\MapperBuilder;
128+
129+
readonly class Order
130+
{
131+
public function __construct(
132+
public int $id,
133+
public string $reference,
134+
public \DateTimeImmutable $createdAt,
135+
) {}
136+
}
137+
138+
$mapper = type_mapper(
139+
type_structure([
140+
'id' => type_integer(),
141+
'reference' => type_string(),
142+
'createdAt' => type_datetime(),
143+
]),
144+
valinor_builder_mapper(new MapperBuilder(), Order::class),
145+
);
146+
147+
// Row from the driver:
148+
// ['id' => 42, 'reference' => 'ORD-001', 'createdAt' => '2026-01-01 14:30:00']
149+
//
150+
// TypeMapper turns the date string into \DateTimeImmutable; Valinor accepts it
151+
// as-is when populating Order::$createdAt.
152+
$order = $client->fetchInto(
153+
$mapper,
154+
'SELECT id, reference, created_at AS "createdAt" FROM orders WHERE id = $1',
155+
[42],
156+
);
157+
```
158+
159+
The same composition handles `UUID` (`type_uuid()`), nested `JSONB` arrays into typed lists (`type_list(type_structure([...]))`), optional fields (`type_optional(...)`), and any other Type from `flow-php/types`. See [TypeMapper](/documentation/components/libs/postgresql/client-type-mapper.md) for the full list of mapper helpers shipped with the postgresql library.
160+
161+
## Error Handling
162+
163+
Both bridge mappers catch Valinor's `CuyZ\Valinor\Mapper\MappingError` and rethrow it as `Flow\PostgreSql\Client\Exception\MappingException`, preserving the original error as the previous exception:
164+
165+
```php
166+
<?php
167+
168+
use Flow\PostgreSql\Client\Exception\MappingException;
169+
use CuyZ\Valinor\Mapper\MappingError;
170+
171+
try {
172+
$user = $client->fetchInto($mapper, 'SELECT ... FROM users WHERE id = $1', [1]);
173+
} catch (MappingException $e) {
174+
/** @var MappingError $valinorError */
175+
$valinorError = $e->getPrevious();
176+
177+
foreach ($valinorError->messages() as $message) {
178+
// Inspect Valinor's per-node messages, e.g. for logging or surfacing in an API
179+
echo $message->path() . ': ' . $message . PHP_EOL;
180+
}
181+
}
182+
```

0 commit comments

Comments
 (0)