Skip to content

Commit 48d485d

Browse files
committed
feature: introduce postgresql mapping context
1 parent 71e5cd4 commit 48d485d

10 files changed

Lines changed: 504 additions & 17 deletions

File tree

documentation/components/libs/postgresql.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,7 @@ The library ships two default mappers, both available via DSL functions, plus an
370370
| Mapper | Use for |
371371
| --- | --- |
372372
| [ConstructorMapper](/documentation/components/libs/postgresql/client-constructor-mapper.md) | Map row columns directly to constructor parameters by name (1:1). No type coercion. |
373+
| [StaticFactoryMapper](/documentation/components/libs/postgresql/client-static-factory-mapper.md) | Delegate row → object construction to a public static factory method (`self::fromRow(array $row)`). Useful when the target class has a private constructor or needs custom coercion inside the factory. |
373374
| [TypeMapper](/documentation/components/libs/postgresql/client-type-mapper.md) | Validate and coerce the row via [flow-php/types](/documentation/components/libs/types.md) (JSONB → structure, date string → `\DateTimeImmutable`, …). Optionally chains into another `RowMapper`. |
374375
| [PostgreSQL Valinor Bridge](/documentation/components/bridges/postgresql-valinor-bridge.md) ⚠️ | Strict object hydration of complex graphs via cuyz/valinor. **Requires the separate `flow-php/postgresql-valinor-bridge` package.** |
375376

@@ -380,6 +381,8 @@ The library ships two default mappers, both available via DSL functions, plus an
380381
- [Fetching Data](/documentation/components/libs/postgresql/client-fetching.md) - fetch, fetchOne, fetchAll, fetchScalar
381382
- [ConstructorMapper](/documentation/components/libs/postgresql/client-constructor-mapper.md) - Map rows directly to
382383
constructor parameters
384+
- [StaticFactoryMapper](/documentation/components/libs/postgresql/client-static-factory-mapper.md) - Map rows via a
385+
public static factory method on the target class
383386
- [TypeMapper](/documentation/components/libs/postgresql/client-type-mapper.md) - Validate and coerce rows via
384387
flow-php/types; chain into another mapper
385388
- [PostgreSQL Valinor Bridge](/documentation/components/bridges/postgresql-valinor-bridge.md) - Strict object
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# StaticFactoryMapper
2+
3+
- [⬅️ Back](/documentation/components/libs/postgresql.md)
4+
5+
[TOC]
6+
7+
`StaticFactoryMapper` delegates row → object construction to a **public static** factory method on the target class. Use it when:
8+
9+
- The target class has a `private` constructor and a named static factory (`User::fromRow(...)`),
10+
- You want to keep row-to-object logic on the domain class itself instead of scattering it across mapper implementations,
11+
- You need custom coercion inside the factory (e.g. `created_at` string → `\DateTimeImmutable`, JSONB string → decoded array).
12+
13+
The factory method **must** have this exact signature:
14+
15+
```php
16+
public static function fromRow(array $row) : self;
17+
```
18+
19+
The method **does not** receive the mapping `Context`. If your factory needs access to the originating `Query`, executing `Client`, or user-supplied data, implement [`RowMapper`](/documentation/components/libs/postgresql.md#row-mappers) directly — see the *Custom RowMapper* section of the [ConstructorMapper documentation](/documentation/components/libs/postgresql/client-constructor-mapper.md#custom-rowmapper).
20+
21+
For simple 1:1 constructor-parameter mapping, use [`ConstructorMapper`](/documentation/components/libs/postgresql/client-constructor-mapper.md). For type-driven coercion via `flow-php/types`, use [`TypeMapper`](/documentation/components/libs/postgresql/client-type-mapper.md).
22+
23+
## DSL
24+
25+
```php
26+
static_factory_mapper(class-string<T> $class, non-empty-string $method) : StaticFactoryMapper<T>
27+
```
28+
29+
Returns a `RowMapper<T>` ready for `fetchInto` / `fetchOneInto` / `fetchAllInto` / `Cursor::map()`.
30+
31+
## Basic Usage
32+
33+
```php
34+
<?php
35+
36+
use function Flow\PostgreSql\DSL\{pgsql_client, pgsql_connection, static_factory_mapper};
37+
38+
final readonly class User
39+
{
40+
private function __construct(
41+
public int $id,
42+
public string $name,
43+
public string $email,
44+
public \DateTimeImmutable $createdAt,
45+
) {
46+
}
47+
48+
/**
49+
* @param array<string, mixed> $row
50+
*/
51+
public static function fromRow(array $row) : self
52+
{
53+
return new self(
54+
id: (int) $row['id'],
55+
name: (string) $row['name'],
56+
email: (string) $row['email'],
57+
createdAt: new \DateTimeImmutable((string) $row['created_at']),
58+
);
59+
}
60+
}
61+
62+
$client = pgsql_client(pgsql_connection('host=localhost dbname=mydb'));
63+
64+
$user = $client->fetchOneInto(
65+
static_factory_mapper(User::class, 'fromRow'),
66+
'SELECT id, name, email, created_at FROM users WHERE id = $1',
67+
[1],
68+
);
69+
```
70+
71+
## fetchInto() — First Object or Null
72+
73+
Returns the first row mapped via the factory, or `null` if no rows:
74+
75+
```php
76+
<?php
77+
78+
$user = $client->fetchInto(
79+
static_factory_mapper(User::class, 'fromRow'),
80+
'SELECT id, name, email, created_at FROM users WHERE email = $1',
81+
['john@example.com'],
82+
);
83+
```
84+
85+
## fetchAllInto() — All Objects
86+
87+
```php
88+
<?php
89+
90+
/** @var User[] $users */
91+
$users = $client->fetchAllInto(
92+
static_factory_mapper(User::class, 'fromRow'),
93+
'SELECT id, name, email, created_at FROM users ORDER BY name',
94+
);
95+
96+
foreach ($users as $user) {
97+
echo $user->name;
98+
}
99+
```
100+
101+
## Streaming via Cursor
102+
103+
```php
104+
<?php
105+
106+
$cursor = $client->cursor('SELECT id, name, email, created_at FROM large_users');
107+
108+
foreach ($cursor->map(static_factory_mapper(User::class, 'fromRow')) as $user) {
109+
processUser($user);
110+
}
111+
```
112+
113+
See [Cursors](/documentation/components/libs/postgresql/client-cursor.md) for details.
114+
115+
## Error Handling
116+
117+
`StaticFactoryMapper` throws `Flow\PostgreSql\Client\Exception\MappingException` in these cases:
118+
119+
| Condition | Message pattern |
120+
|---------------------------------------------------|-------------------------------------------------------|
121+
| `$class` does not exist | `Failed to map row to "<class>": Class does not exist`|
122+
| Factory method does not exist on `$class` | `Static factory method "<class>::<method>()" does not exist` |
123+
| Factory method exists but is not declared `static`| `Factory method "<class>::<method>()" must be declared static` |
124+
| Factory method is declared but not `public` | `Factory method "<class>::<method>()" must be declared public` |
125+
| Factory method throws any `\Throwable` | `Failed to map row to "<class>": <original message>`, with the original exception available via `MappingException::getPrevious()` |
126+
127+
Validation of `$class` / `$method` (existence, `static`, `public`) runs **eagerly in the constructor** — a misconfigured mapper fails fast at wiring time, not at query time. Exceptions thrown by the factory method itself surface on the matching `map()` call and are wrapped in a `MappingException` whose `getPrevious()` returns the original throwable.
128+
129+
## When to Reach for Something Else
130+
131+
| Need | Use |
132+
|---------------------------------------------------------------------|---------------------------------------------------------------------------------------|
133+
| 1:1 column-to-constructor-parameter mapping with no coercion | [`ConstructorMapper`](/documentation/components/libs/postgresql/client-constructor-mapper.md) |
134+
| Row validation / coercion via `flow-php/types` | [`TypeMapper`](/documentation/components/libs/postgresql/client-type-mapper.md) |
135+
| Access to `Context` (query / parameters / client / catalog) in your mapping logic | Implement [`RowMapper`](/documentation/components/libs/postgresql.md#row-mappers) directly |
136+
| Strict object hydration of complex graphs | [PostgreSQL Valinor Bridge](/documentation/components/bridges/postgresql-valinor-bridge.md) |

src/lib/postgresql/src/Flow/PostgreSql/Client/Exception/MappingException.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,21 @@
66

77
final class MappingException extends ClientException
88
{
9+
public static function factoryMethodNotFound(string $class, string $method) : self
10+
{
11+
return new self(\sprintf('Static factory method "%s::%s()" does not exist', $class, $method));
12+
}
13+
14+
public static function factoryMethodNotPublic(string $class, string $method) : self
15+
{
16+
return new self(\sprintf('Factory method "%s::%s()" must be declared public', $class, $method));
17+
}
18+
19+
public static function factoryMethodNotStatic(string $class, string $method) : self
20+
{
21+
return new self(\sprintf('Factory method "%s::%s()" must be declared static', $class, $method));
22+
}
23+
924
public static function mappingFailed(string $class, string $reason, ?\Throwable $previous = null) : self
1025
{
1126
return new self(\sprintf('Failed to map row to "%s": %s', $class, $reason), 0, $previous);

src/lib/postgresql/src/Flow/PostgreSql/Client/RowMapper/ConstructorMapper.php

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,33 +22,42 @@
2222
*/
2323
final readonly class ConstructorMapper implements RowMapper
2424
{
25+
/** @var list<\ReflectionParameter> */
26+
private array $parameters;
27+
28+
/** @var \ReflectionClass<T> */
29+
private \ReflectionClass $reflection;
30+
2531
/**
2632
* @param class-string<T> $class
33+
*
34+
* @throws MappingException
2735
*/
2836
public function __construct(private string $class)
29-
{
30-
}
31-
32-
/**
33-
* @return T
34-
*/
35-
public function map(array $row, Context $context) : object
3637
{
3738
if (!\class_exists($this->class)) {
3839
throw MappingException::mappingFailed($this->class, 'Class does not exist');
3940
}
4041

41-
$reflection = new \ReflectionClass($this->class);
42+
$this->reflection = new \ReflectionClass($this->class);
4243

43-
$constructor = $reflection->getConstructor();
44+
$constructor = $this->reflection->getConstructor();
4445

4546
if ($constructor === null) {
4647
throw MappingException::mappingFailed($this->class, 'Class has no constructor');
4748
}
4849

50+
$this->parameters = $constructor->getParameters();
51+
}
52+
53+
/**
54+
* @return T
55+
*/
56+
public function map(array $row, Context $context) : object
57+
{
4958
$args = [];
5059

51-
foreach ($constructor->getParameters() as $param) {
60+
foreach ($this->parameters as $param) {
5261
$paramName = $param->getName();
5362

5463
if (\array_key_exists($paramName, $row)) {
@@ -63,7 +72,7 @@ public function map(array $row, Context $context) : object
6372
}
6473

6574
try {
66-
return $reflection->newInstanceArgs($args);
75+
return $this->reflection->newInstanceArgs($args);
6776
} catch (\Throwable $e) {
6877
throw MappingException::mappingFailed($this->class, $e->getMessage());
6978
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Flow\PostgreSql\Client\RowMapper;
6+
7+
use Flow\PostgreSql\Client\Exception\MappingException;
8+
use Flow\PostgreSql\Client\RowMapper;
9+
10+
/**
11+
* Maps database rows to objects using a user-provided public static factory method.
12+
*
13+
* The factory method must:
14+
* - be public,
15+
* - be static,
16+
* - accept a single parameter: array<string, mixed> $row,
17+
* - return an instance of $class (or a subclass).
18+
*
19+
* If the factory needs access to the mapping Context (sql/parameters/client/catalog/user-data),
20+
* implement Flow\PostgreSql\Client\RowMapper directly instead — this mapper intentionally hides Context.
21+
*
22+
* @template T of object
23+
*
24+
* @implements RowMapper<T>
25+
*/
26+
final readonly class StaticFactoryMapper implements RowMapper
27+
{
28+
/**
29+
* @param class-string<T> $class
30+
* @param non-empty-string $method
31+
*
32+
* @throws MappingException
33+
*/
34+
public function __construct(
35+
private string $class,
36+
private string $method,
37+
) {
38+
if (!\class_exists($this->class)) {
39+
throw MappingException::mappingFailed($this->class, 'Class does not exist');
40+
}
41+
42+
if (!\method_exists($this->class, $this->method)) {
43+
throw MappingException::factoryMethodNotFound($this->class, $this->method);
44+
}
45+
46+
$reflection = new \ReflectionMethod($this->class, $this->method);
47+
48+
if (!$reflection->isStatic()) {
49+
throw MappingException::factoryMethodNotStatic($this->class, $this->method);
50+
}
51+
52+
if (!$reflection->isPublic()) {
53+
throw MappingException::factoryMethodNotPublic($this->class, $this->method);
54+
}
55+
}
56+
57+
/**
58+
* @return T
59+
*/
60+
public function map(array $row, Context $context) : object
61+
{
62+
try {
63+
/** @var T */
64+
return $this->class::{$this->method}($row);
65+
} catch (\Throwable $e) {
66+
throw MappingException::mappingFailed($this->class, $e->getMessage(), $e);
67+
}
68+
}
69+
}

src/lib/postgresql/src/Flow/PostgreSql/DSL/client.php

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
use Flow\PostgreSql\Client\{DsnParser, RowMapper};
1111
use Flow\PostgreSql\Client\Exception\ConnectionException;
1212
use Flow\PostgreSql\Client\Infrastructure\PgSql\PgSqlClient;
13-
use Flow\PostgreSql\Client\RowMapper\{ConstructorMapper, TypeMapper};
13+
use Flow\PostgreSql\Client\RowMapper\{ConstructorMapper, StaticFactoryMapper, TypeMapper};
1414
use Flow\PostgreSql\Client\Telemetry\{PostgreSqlTelemetryConfig, PostgreSqlTelemetryOptions, TraceableClient};
1515
use Flow\PostgreSql\Client\Types\{ValueConverters, ValueType};
1616
use Flow\PostgreSql\Schema\Catalog;
@@ -305,6 +305,26 @@ function type_mapper(FlowType $type, ?RowMapper $next = null) : TypeMapper
305305
return new TypeMapper($type, $next);
306306
}
307307

308+
/**
309+
* Create a row mapper backed by a public static factory method.
310+
*
311+
* The factory method must accept a single array<string, mixed> $row and return
312+
* an instance of the target class. If your factory needs access to the mapping
313+
* Context (sql/parameters/client/catalog/user-data), implement RowMapper directly.
314+
*
315+
* @template T of object
316+
*
317+
* @param class-string<T> $class
318+
* @param non-empty-string $method
319+
*
320+
* @return StaticFactoryMapper<T>
321+
*/
322+
#[DocumentationDSL(module: Module::PG_QUERY, type: DSLType::HELPER)]
323+
function static_factory_mapper(string $class, string $method) : StaticFactoryMapper
324+
{
325+
return new StaticFactoryMapper($class, $method);
326+
}
327+
308328
/**
309329
* Wrap a value with explicit PostgreSQL type information for parameter binding.
310330
*

src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Client/RowMapper/ConstructorMapperTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,7 @@ public function test_throws_for_class_without_constructor() : void
248248
$this->expectException(MappingException::class);
249249
$this->expectExceptionMessage('Class has no constructor');
250250

251-
(new ConstructorMapper(NoConstructorDto::class))->map(['id' => 1], MapperContextMother::any());
251+
new ConstructorMapper(NoConstructorDto::class);
252252
}
253253

254254
public function test_throws_for_missing_required_parameter() : void

0 commit comments

Comments
 (0)