Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions documentation/components/libs/postgresql.md
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,7 @@ The library ships two default mappers, both available via DSL functions, plus an
| Mapper | Use for |
| --- | --- |
| [ConstructorMapper](/documentation/components/libs/postgresql/client-constructor-mapper.md) | Map row columns directly to constructor parameters by name (1:1). No type coercion. |
| [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. |
| [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`. |
| [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.** |

Expand All @@ -380,6 +381,8 @@ The library ships two default mappers, both available via DSL functions, plus an
- [Fetching Data](/documentation/components/libs/postgresql/client-fetching.md) - fetch, fetchOne, fetchAll, fetchScalar
- [ConstructorMapper](/documentation/components/libs/postgresql/client-constructor-mapper.md) - Map rows directly to
constructor parameters
- [StaticFactoryMapper](/documentation/components/libs/postgresql/client-static-factory-mapper.md) - Map rows via a
public static factory method on the target class
- [TypeMapper](/documentation/components/libs/postgresql/client-type-mapper.md) - Validate and coerce rows via
flow-php/types; chain into another mapper
- [PostgreSQL Valinor Bridge](/documentation/components/bridges/postgresql-valinor-bridge.md) - Strict object
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# StaticFactoryMapper

- [⬅️ Back](/documentation/components/libs/postgresql.md)

[TOC]

`StaticFactoryMapper` delegates row → object construction to a **public static** factory method on the target class. Use it when:

- The target class has a `private` constructor and a named static factory (`User::fromRow(...)`),
- You want to keep row-to-object logic on the domain class itself instead of scattering it across mapper implementations,
- You need custom coercion inside the factory (e.g. `created_at` string → `\DateTimeImmutable`, JSONB string → decoded array).

The factory method **must** have this exact signature:

```php
public static function fromRow(array $row) : self;
```

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).

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).

## DSL

```php
static_factory_mapper(class-string<T> $class, non-empty-string $method) : StaticFactoryMapper<T>
```

Returns a `RowMapper<T>` ready for `fetchInto` / `fetchOneInto` / `fetchAllInto` / `Cursor::map()`.

## Basic Usage

```php
<?php

use function Flow\PostgreSql\DSL\{pgsql_client, pgsql_connection, static_factory_mapper};

final readonly class User
{
private function __construct(
public int $id,
public string $name,
public string $email,
public \DateTimeImmutable $createdAt,
) {
}

/**
* @param array<string, mixed> $row
*/
public static function fromRow(array $row) : self
{
return new self(
id: (int) $row['id'],
name: (string) $row['name'],
email: (string) $row['email'],
createdAt: new \DateTimeImmutable((string) $row['created_at']),
);
}
}

$client = pgsql_client(pgsql_connection('host=localhost dbname=mydb'));

$user = $client->fetchOneInto(
static_factory_mapper(User::class, 'fromRow'),
'SELECT id, name, email, created_at FROM users WHERE id = $1',
[1],
);
```

## fetchInto() — First Object or Null

Returns the first row mapped via the factory, or `null` if no rows:

```php
<?php

$user = $client->fetchInto(
static_factory_mapper(User::class, 'fromRow'),
'SELECT id, name, email, created_at FROM users WHERE email = $1',
['john@example.com'],
);
```

## fetchAllInto() — All Objects

```php
<?php

/** @var User[] $users */
$users = $client->fetchAllInto(
static_factory_mapper(User::class, 'fromRow'),
'SELECT id, name, email, created_at FROM users ORDER BY name',
);

foreach ($users as $user) {
echo $user->name;
}
```

## Streaming via Cursor

```php
<?php

$cursor = $client->cursor('SELECT id, name, email, created_at FROM large_users');

foreach ($cursor->map(static_factory_mapper(User::class, 'fromRow')) as $user) {
processUser($user);
}
```

See [Cursors](/documentation/components/libs/postgresql/client-cursor.md) for details.

## Error Handling

`StaticFactoryMapper` throws `Flow\PostgreSql\Client\Exception\MappingException` in these cases:

| Condition | Message pattern |
|---------------------------------------------------|-------------------------------------------------------|
| `$class` does not exist | `Failed to map row to "<class>": Class does not exist`|
| Factory method does not exist on `$class` | `Static factory method "<class>::<method>()" does not exist` |
| Factory method exists but is not declared `static`| `Factory method "<class>::<method>()" must be declared static` |
| Factory method is declared but not `public` | `Factory method "<class>::<method>()" must be declared public` |
| Factory method throws any `\Throwable` | `Failed to map row to "<class>": <original message>`, with the original exception available via `MappingException::getPrevious()` |

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.

## When to Reach for Something Else

| Need | Use |
|---------------------------------------------------------------------|---------------------------------------------------------------------------------------|
| 1:1 column-to-constructor-parameter mapping with no coercion | [`ConstructorMapper`](/documentation/components/libs/postgresql/client-constructor-mapper.md) |
| Row validation / coercion via `flow-php/types` | [`TypeMapper`](/documentation/components/libs/postgresql/client-type-mapper.md) |
| Access to `Context` (query / parameters / client / catalog) in your mapping logic | Implement [`RowMapper`](/documentation/components/libs/postgresql.md#row-mappers) directly |
| Strict object hydration of complex graphs | [PostgreSQL Valinor Bridge](/documentation/components/bridges/postgresql-valinor-bridge.md) |
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

namespace Flow\Bridge\PHPUnit\PostgreSQL;

use Flow\PostgreSql\Client\{Client, ConnectionParameters};
use Flow\PostgreSql\Client\{Client, ConnectionParameters, Context};
use Flow\PostgreSql\Client\Infrastructure\PgSql\PgSqlClient;
use Flow\PostgreSql\Client\Types\ValueConverters;

Expand Down Expand Up @@ -51,16 +51,16 @@ public static function closeAll() : void
self::$clients = [];
}

public static function connect(ConnectionParameters $params, ?ValueConverters $converters = null) : Client
public static function connect(ConnectionParameters $params, ?ValueConverters $converters = null, ?Context $context = null) : Client
{
if (!self::$enabled) {
return PgSqlClient::connect($params, $converters);
return PgSqlClient::connect($params, $converters, $context);
}

$key = \sha1(\sprintf('%s:%d:%s:%s', $params->host(), $params->port(), $params->database(), $params->user() ?? ''));

if (!\array_key_exists($key, self::$clients) || !self::$clients[$key]->isConnected()) {
self::$clients[$key] = PgSqlClient::connect($params, $converters);
self::$clients[$key] = PgSqlClient::connect($params, $converters, $context);

if (self::$transactionActive) {
self::$clients[$key]->beginTransaction();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use CuyZ\Valinor\MapperBuilder;
use Flow\PostgreSql\Client\Exception\MappingException;
use Flow\PostgreSql\Client\RowMapper;
use Flow\PostgreSql\Client\RowMapper\Context;

/**
* @template T of object
Expand All @@ -33,7 +34,7 @@ public function __construct(
*
* @return T
*/
public function map(array $row) : mixed
public function map(array $row, Context $context) : mixed
{
try {
return $this->mapper->map($this->class, $row);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use CuyZ\Valinor\Mapper\{MappingError, TreeMapper};
use Flow\PostgreSql\Client\Exception\MappingException;
use Flow\PostgreSql\Client\RowMapper;
use Flow\PostgreSql\Client\RowMapper\Context;

/**
* @template T of object
Expand All @@ -29,7 +30,7 @@ public function __construct(
*
* @return T
*/
public function map(array $row) : mixed
public function map(array $row, Context $context) : mixed
{
try {
return $this->mapper->map($this->class, $row);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use CuyZ\Valinor\MapperBuilder;
use Flow\PostgreSql\Bridge\Valinor\Tests\Unit\Fixture\SimpleDto;
use Flow\PostgreSql\Bridge\Valinor\{ValinorBuilderMapper, ValinorTreeMapper};
use Flow\PostgreSql\Tests\Mother\MapperContextMother;
use PHPUnit\Framework\TestCase;

final class DSLTest extends TestCase
Expand All @@ -19,7 +20,7 @@ public function test_valinor_builder_mapper_delegates_to_class() : void
self::assertInstanceOf(ValinorBuilderMapper::class, $mapper);
self::assertSame(
1,
$mapper->map(['id' => 1, 'name' => 'Jane', 'email' => 'jane@example.com'])->id,
$mapper->map(['id' => 1, 'name' => 'Jane', 'email' => 'jane@example.com'], MapperContextMother::any())->id,
);
}

Expand All @@ -30,7 +31,7 @@ public function test_valinor_tree_mapper_delegates_to_class() : void
self::assertInstanceOf(ValinorTreeMapper::class, $mapper);
self::assertSame(
1,
$mapper->map(['id' => 1, 'name' => 'Jane', 'email' => 'jane@example.com'])->id,
$mapper->map(['id' => 1, 'name' => 'Jane', 'email' => 'jane@example.com'], MapperContextMother::any())->id,
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use Flow\PostgreSql\Bridge\Valinor\Tests\Unit\Fixture\{Address, SimpleDto, UserWithAddress};
use Flow\PostgreSql\Bridge\Valinor\ValinorBuilderMapper;
use Flow\PostgreSql\Client\Exception\MappingException;
use Flow\PostgreSql\Tests\Mother\MapperContextMother;
use PHPUnit\Framework\TestCase;

final class ValinorBuilderMapperTest extends TestCase
Expand All @@ -33,7 +34,7 @@ public function test_composes_with_type_mapper_to_decode_jsonb_into_nested_objec
'id' => 1,
'name' => 'Jane',
'address' => '{"street":"Main 1","city":"Warsaw"}',
]);
], MapperContextMother::any());

self::assertInstanceOf(UserWithAddress::class, $result);
self::assertInstanceOf(Address::class, $result->address);
Expand All @@ -45,8 +46,8 @@ public function test_map_succeeds_twice_on_same_instance() : void
{
$mapper = new ValinorBuilderMapper(new MapperBuilder(), SimpleDto::class);

$first = $mapper->map(['id' => 1, 'name' => 'Jane', 'email' => 'jane@example.com']);
$second = $mapper->map(['id' => 2, 'name' => 'John', 'email' => 'john@example.com']);
$first = $mapper->map(['id' => 1, 'name' => 'Jane', 'email' => 'jane@example.com'], MapperContextMother::any());
$second = $mapper->map(['id' => 2, 'name' => 'John', 'email' => 'john@example.com'], MapperContextMother::any());

self::assertSame(1, $first->id);
self::assertSame(2, $second->id);
Expand All @@ -60,7 +61,7 @@ public function test_maps_row_to_simple_dto() : void
'id' => 1,
'name' => 'Jane',
'email' => 'jane@example.com',
]);
], MapperContextMother::any());

self::assertInstanceOf(SimpleDto::class, $result);
self::assertSame(1, $result->id);
Expand All @@ -73,7 +74,7 @@ public function test_wraps_mapping_error_as_mapping_exception_with_previous() :
$mapper = new ValinorBuilderMapper(new MapperBuilder(), SimpleDto::class);

try {
$mapper->map(['id' => 'not-an-int']);
$mapper->map(['id' => 'not-an-int'], MapperContextMother::any());
self::fail('Expected MappingException was not thrown');
} catch (MappingException $e) {
self::assertInstanceOf(MappingError::class, $e->getPrevious());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use Flow\PostgreSql\Bridge\Valinor\Tests\Unit\Fixture\{Address, NullableDto, SimpleDto, UserWithAddress, WithDateTime, WithTags};
use Flow\PostgreSql\Bridge\Valinor\ValinorTreeMapper;
use Flow\PostgreSql\Client\Exception\MappingException;
use Flow\PostgreSql\Tests\Mother\MapperContextMother;
use PHPUnit\Framework\TestCase;

final class ValinorTreeMapperTest extends TestCase
Expand All @@ -27,7 +28,7 @@ public function test_allows_extra_columns_when_superfluous_keys_enabled() : void
'name' => 'Jane',
'email' => 'jane@example.com',
'extra' => 'ignored',
]);
], MapperContextMother::any());

self::assertSame(1, $result->id);
self::assertSame('Jane', $result->name);
Expand All @@ -47,7 +48,7 @@ public function test_composes_with_type_mapper_for_datetime_columns() : void
$result = $mapper->map([
'id' => 7,
'createdAt' => '2024-03-15 14:30:00',
]);
], MapperContextMother::any());

self::assertInstanceOf(WithDateTime::class, $result);
self::assertSame(7, $result->id);
Expand All @@ -72,7 +73,7 @@ public function test_composes_with_type_mapper_to_decode_jsonb_into_nested_objec
'id' => 1,
'name' => 'Jane',
'address' => '{"street":"Main 1","city":"Warsaw"}',
]);
], MapperContextMother::any());

self::assertInstanceOf(UserWithAddress::class, $result);
self::assertInstanceOf(Address::class, $result->address);
Expand All @@ -85,7 +86,7 @@ public function test_exception_message_includes_target_class() : void
$mapper = new ValinorTreeMapper((new MapperBuilder())->mapper(), SimpleDto::class);

try {
$mapper->map(['id' => 1]);
$mapper->map(['id' => 1], MapperContextMother::any());
self::fail('Expected MappingException was not thrown');
} catch (MappingException $e) {
self::assertStringStartsWith(
Expand All @@ -102,7 +103,7 @@ public function test_maps_list_of_strings() : void
$result = $mapper->map([
'id' => 1,
'tags' => ['php', 'postgresql', 'flow'],
]);
], MapperContextMother::any());

self::assertSame(['php', 'postgresql', 'flow'], $result->tags);
}
Expand All @@ -118,7 +119,7 @@ public function test_maps_nested_object_from_array() : void
'street' => 'Main 1',
'city' => 'Warsaw',
],
]);
], MapperContextMother::any());

self::assertSame(1, $result->id);
self::assertSame('Jane', $result->name);
Expand All @@ -134,7 +135,7 @@ public function test_maps_nullable_property_when_value_is_null() : void
'id' => 1,
'name' => 'Jane',
'nickname' => null,
]);
], MapperContextMother::any());

self::assertSame(1, $result->id);
self::assertSame('Jane', $result->name);
Expand All @@ -149,7 +150,7 @@ public function test_maps_row_to_simple_dto() : void
'id' => 1,
'name' => 'Jane',
'email' => 'jane@example.com',
]);
], MapperContextMother::any());

self::assertInstanceOf(SimpleDto::class, $result);
self::assertSame(1, $result->id);
Expand All @@ -168,15 +169,15 @@ public function test_rejects_extra_columns_by_default() : void
'name' => 'Jane',
'email' => 'jane@example.com',
'extra' => 'boom',
]);
], MapperContextMother::any());
}

public function test_wraps_mapping_error_as_mapping_exception_with_previous() : void
{
$mapper = new ValinorTreeMapper((new MapperBuilder())->mapper(), SimpleDto::class);

try {
$mapper->map(['id' => 'not-an-int']);
$mapper->map(['id' => 'not-an-int'], MapperContextMother::any());
self::fail('Expected MappingException was not thrown');
} catch (MappingException $e) {
self::assertInstanceOf(MappingError::class, $e->getPrevious());
Expand Down
Loading
Loading