Skip to content

Commit 3c0d1f3

Browse files
innocenzibrendt
authored andcommitted
feat(mapper): support contextual serializers and casters (#1791)
1 parent 8bb394d commit 3c0d1f3

File tree

94 files changed

+1790
-1028
lines changed

Some content is hidden

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

94 files changed

+1790
-1028
lines changed

docs/1-essentials/03-database.md

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -308,34 +308,41 @@ final class User
308308

309309
The encryption key is taken from the `SIGNING_KEY` environment variable.
310310

311-
### DTO properties
311+
### Data transfer object properties
312312

313-
Sometimes, you might want to store data objects as-is in a table, without there needing to be a relation to another table. To do so, it's enough to add a serializer and caster to the data object's class, and Tempest will know that these objects aren't meant to be treated as database models. Next, you can store the object's data as a json field on the table (see [migrations](#migrations) for more info).
313+
You can store arbitrary objects directly in a `json` column when they don’t need to be part of the relational schema.
314+
315+
To do this, annotate the class with `⁠#[Tempest\Mapper\SerializeAs]` and provide a unique identifier for the object’s serialized form. The identifier must map to a single, distinct class.
314316

315317
```php
316-
use Tempest\Database\IsDatabaseModel;
317-
use Tempest\Mapper\CastWith;
318-
use Tempest\Mapper\SerializeWith;
319-
use Tempest\Mapper\Casters\DtoCaster;
320-
use Tempest\Mapper\Serializers\DtoSerializer;
318+
use Tempest\Mapper\SerializeAs;
321319

322-
final class DebugItem
320+
final class User implements Authenticatable
323321
{
324-
use IsDatabaseModel;
325-
326-
/* … */
327-
328-
public Backtrace $backtrace,
322+
public PrimaryKey $id;
323+
324+
public function __construct(
325+
public string $email,
326+
#[Hashed, SensitiveParameter]
327+
public ?string $password,
328+
public Settings $settings,
329+
) {}
329330
}
330331

331-
#[CastWith(DtoCaster::class)]
332-
#[SerializeWith(DtoSerializer::class)]
333-
final class Backtrace
332+
#[SerializeAs('user_settings')]
333+
final class Settings
334334
{
335-
// This object won't be considered a relation,
336-
// but rather serialized and stored in a JSON column.
335+
public function __construct(
336+
public readonly Theme $theme,
337+
public readonly bool $hide_sidebar_by_default,
338+
) {}
339+
}
337340

338-
public array $frames = [];
341+
enum Theme: string
342+
{
343+
case DARK = 'dark';
344+
case LIGHT = 'light';
345+
case AUTO = 'auto';
339346
}
340347
```
341348

docs/2-features/01-mapper.md

Lines changed: 169 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,38 @@ final readonly class AddressSerializer implements Serializer
202202

203203
Of course, Tempest provides casters and serializers for the most common data types, including arrays, booleans, dates, enumerations, integers and value objects.
204204

205+
### Registering casters and serializers globally
206+
207+
You may register casters and serializers globally, so you don't have to specify them for every property. This is useful for value objects that are used frequently. To do so, you may implement the {`\Tempest\Mapper\DynamicCaster`} or {`\Tempest\Mapper\DynamicSerializer`} interface, which require an `accepts` method:
208+
209+
```php app/AddressSerializer.php
210+
use Tempest\Mapper\Serializer;
211+
use Tempest\Mapper\DynamicSerializer;
212+
213+
final readonly class AddressSerializer implements Serializer, DynamicSerializer
214+
{
215+
public static function accepts(PropertyReflector|TypeReflector $input): bool
216+
{
217+
$type = $input instanceof PropertyReflector
218+
? $input->getType()
219+
: $input;
220+
221+
return $type->matches(Address::class);
222+
}
223+
224+
public function serialize(mixed $input): array|string
225+
{
226+
if (! $input instanceof Address) {
227+
throw new CannotSerializeValue(Address::class);
228+
}
229+
230+
return $input->toArray();
231+
}
232+
}
233+
```
234+
235+
Dynamic serializers and casters will automatically be discovered by Tempest.
236+
205237
### Specifying casters or serializers for properties
206238

207239
You may use a specific caster or serializer for a property by using the {b`#[Tempest\Mapper\CastWith]`} or {b`#[Tempest\Mapper\SerializeWith]`} attribute, respectively.
@@ -218,21 +250,147 @@ final class User
218250

219251
You may of course use {b`#[Tempest\Mapper\CastWith]`} and {b`#[Tempest\Mapper\SerializeWith]`} together.
220252

221-
### Registering casters and serializers globally
253+
## Mapping contexts
222254

223-
You may register casters and serializers globally, so you don't have to specify them for every property. This is useful for value objects that are used frequently.
255+
Contexts allow you to use different casters, serializers, and mappers depending on the situation. For example, you might want to serialize dates differently for an API response versus database storage, or apply different validation rules for different contexts.
256+
257+
### Using contexts
258+
259+
You may specify a context when mapping by using the `in()` method. Contexts can be provided as a string, an enum, or a {b`\Tempest\Mapper\Context`} object.
224260

225261
```php
226-
use Tempest\Mapper\Casters\CasterFactory;
227-
use Tempest\Mapper\Serializers\SerializerFactory;
262+
use App\SerializationContext;
263+
use function Tempest\Mapper\map;
228264

229-
// Register a caster globally for a specific type
230-
$container->get(CasterFactory::class)
231-
->addCaster(Address::class, AddressCaster::class);
265+
$json = map($book)
266+
->in(SerializationContext::API)
267+
->toJson();
268+
```
269+
270+
To create a caster or serializer that only applies in a specific context, use the {b`#[Tempest\Mapper\Attributes\Context]`} attribute on your class:
271+
272+
```php
273+
use Tempest\DateTime\DateTime;
274+
use Tempest\DateTime\FormatPattern;
275+
use Tempest\Mapper\Attributes\Context;
276+
use Tempest\Mapper\Serializer;
277+
use Tempest\Mapper\DynamicSerializer;
232278

233-
// Register a serializer globally for a specific type
234-
$container->get(SerializerFactory::class)
235-
->addSerializer(Address::class, AddressSerializer::class);
279+
#[Context(SerializationContext::API)]
280+
final readonly class ApiDateSerializer implements Serializer, DynamicSerializer
281+
{
282+
public static function accepts(PropertyReflector|TypeReflector $input): bool
283+
{
284+
$type = $input instanceof PropertyReflector
285+
? $input->getType()
286+
: $input;
287+
288+
return $type->matches(DateTime::class);
289+
}
290+
291+
public function serialize(mixed $input): string
292+
{
293+
return $input->format(FormatPattern::ISO8601);
294+
}
295+
}
236296
```
237297

238-
If you're looking for the right place where to put this logic, [provider classes](/docs/extra-topics/package-development#provider-classes) is our recommendation.
298+
This serializer will only be used when mapping with `->in(SerializationContext::API)`. Without a context specified, or in other contexts, the default serializers will be used.
299+
300+
### Injecting context into casters and serializers
301+
302+
You may inject the current context into your caster or serializer constructor to adapt behavior dynamically. Note that the context property has to be named `$context`. You may also inject any other dependency from the container.
303+
304+
```php
305+
use Tempest\Mapper\Context;
306+
use Tempest\Mapper\Serializer;
307+
308+
#[Context(DatabaseContext::class)]
309+
final class BooleanSerializer implements Serializer, DynamicSerializer
310+
{
311+
public function __construct(
312+
private DatabaseContext $context,
313+
) {}
314+
315+
public static function accepts(PropertyReflector|TypeReflector $type): bool
316+
{
317+
$type = $type instanceof PropertyReflector
318+
? $type->getType()
319+
: $type;
320+
321+
return $type->getName() === 'bool' || $type->getName() === 'boolean';
322+
}
323+
324+
public function serialize(mixed $input): string
325+
{
326+
return match ($this->context->dialect) {
327+
DatabaseDialect::POSTGRESQL => $input ? 'true' : 'false',
328+
default => $input ? '1' : '0',
329+
};
330+
}
331+
}
332+
```
333+
334+
## Configurable casters and serializers
335+
336+
Sometimes, a caster or serializer needs to be configured based on the property it's applied to. For example, an enum caster needs to know which enum class to use, or an object caster needs to know the target type.
337+
338+
Implement the {b`\Tempest\Mapper\ConfigurableCaster`} or {b`\Tempest\Mapper\ConfigurableSerializer`} interface to create casters/serializers that are configured per property:
339+
340+
```php
341+
use Tempest\Mapper\Caster;
342+
use Tempest\Mapper\ConfigurableCaster;
343+
use Tempest\Mapper\Context;
344+
use Tempest\Mapper\DynamicCaster;
345+
use Tempest\Reflection\PropertyReflector;
346+
347+
final readonly class EnumCaster implements Caster, DynamicCaster, ConfigurableCaster
348+
{
349+
/**
350+
* @param class-string<UnitEnum> $enum
351+
*/
352+
public function __construct(
353+
private string $enum,
354+
) {}
355+
356+
public static function accepts(PropertyReflector|TypeReflector $input): bool
357+
{
358+
$type = $input instanceof PropertyReflector
359+
? $input->getType()
360+
: $input;
361+
362+
return $type->matches(UnitEnum::class);
363+
}
364+
365+
public static function configure(PropertyReflector $property, Context $context): self
366+
{
367+
// Create a new instance configured for this specific property
368+
return new self(enum: $property->getType()->getName());
369+
}
370+
371+
public function cast(mixed $input): ?object
372+
{
373+
if ($input === null) {
374+
return null;
375+
}
376+
377+
// Use the configured enum class
378+
return $this->enum::from($input);
379+
}
380+
}
381+
```
382+
383+
The `configure()` method receives the property being mapped and the current context, allowing you to create a caster instance tailored to that specific property.
384+
385+
Note that `ConfigurableSerializer::configure()` can receive either a `PropertyReflector`, `TypeReflector`, or `string`, depending on whether it's being used for property mapping or value serialization.
386+
387+
### When to use configurable casters and serializers
388+
389+
Use configurable casters and serializers when:
390+
391+
- The caster/serializer behavior depends on the specific property type (e.g., enum class, object class)
392+
- You need access to property attributes or metadata
393+
- Different properties of the same base type require different handling
394+
- You want to avoid creating many similar caster/serializer classes
395+
396+
For simple, static behavior that doesn't depend on property information, regular casters and serializers are sufficient.

mago.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
php-version = "8.4.0"
1+
php-version = "8.5.0"
22

33
[source]
44
paths = ["src", "packages", "tests"]

packages/auth/tests/OAuthTest.php

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
use League\OAuth2\Client\Provider\Instagram;
1212
use League\OAuth2\Client\Provider\LinkedIn;
1313
use League\OAuth2\Client\Provider\ResourceOwnerInterface;
14+
use PHPUnit\Framework\Attributes\Before;
1415
use PHPUnit\Framework\Attributes\Test;
1516
use PHPUnit\Framework\TestCase;
1617
use Tempest\Auth\OAuth\Config\AppleOAuthConfig;
@@ -23,6 +24,7 @@
2324
use Tempest\Auth\OAuth\Config\LinkedInOAuthConfig;
2425
use Tempest\Auth\OAuth\OAuthClientInitializer;
2526
use Tempest\Auth\OAuth\OAuthUser;
27+
use Tempest\Container\Container;
2628
use Tempest\Container\GenericContainer;
2729
use Tempest\Mapper\MapperConfig;
2830
use Tempest\Mapper\Mappers\ArrayToObjectMapper;
@@ -31,13 +33,20 @@
3133
final class OAuthTest extends TestCase
3234
{
3335
private GenericContainer $container {
34-
get => $this->container ??= new GenericContainer()->addInitializer(OAuthClientInitializer::class);
36+
get => $this->container ??= new GenericContainer();
3537
}
3638

3739
private ObjectFactory $factory {
3840
get => $this->factory ??= new ObjectFactory(new MapperConfig([ArrayToObjectMapper::class]), $this->container);
3941
}
4042

43+
#[Before]
44+
public function before(): void
45+
{
46+
$this->container->singleton(Container::class, $this->container);
47+
$this->container->addInitializer(OAuthClientInitializer::class);
48+
}
49+
4150
#[Test]
4251
public function github_oauth_config(): void
4352
{

packages/container/src/GenericContainer.php

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -177,11 +177,7 @@ public function get(string $className, null|string|UnitEnum $tag = null, mixed .
177177
{
178178
$this->resolveChain();
179179

180-
$dependency = $this->resolve(
181-
className: $className,
182-
tag: $tag,
183-
params: $params,
184-
);
180+
$dependency = $this->resolve($className, $tag, ...$params);
185181

186182
$this->stopChain();
187183

packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
use Closure;
66
use Tempest\Database\Builder\ModelInspector;
7+
use Tempest\Database\Database;
8+
use Tempest\Database\DatabaseContext;
79
use Tempest\Database\Exceptions\HasManyRelationCouldNotBeInsterted;
810
use Tempest\Database\Exceptions\HasOneRelationCouldNotBeInserted;
911
use Tempest\Database\Exceptions\ModelDidNotHavePrimaryColumn;
@@ -22,6 +24,7 @@
2224
use Tempest\Support\Str\ImmutableString;
2325

2426
use function Tempest\Database\inspect;
27+
use function Tempest\get;
2528
use function Tempest\Support\str;
2629

2730
/**
@@ -40,6 +43,14 @@ final class InsertQueryBuilder implements BuildsQuery
4043

4144
public ModelInspector $model;
4245

46+
private Database $database {
47+
get => get(Database::class, $this->onDatabase);
48+
}
49+
50+
private DatabaseContext $context {
51+
get => new DatabaseContext(dialect: $this->database->dialect);
52+
}
53+
4354
/**
4455
* @param class-string<TModel>|string|TModel $model
4556
*/
@@ -469,7 +480,10 @@ private function serializeValue(PropertyReflector $property, mixed $value): mixe
469480
return null;
470481
}
471482

472-
return $this->serializerFactory->forProperty($property)?->serialize($value) ?? $value;
483+
return $this->serializerFactory
484+
->in($this->context)
485+
->forProperty($property)
486+
?->serialize($value) ?? $value;
473487
}
474488

475489
private function serializeIterableValue(string $key, mixed $value): mixed

0 commit comments

Comments
 (0)