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
45 changes: 26 additions & 19 deletions docs/1-essentials/03-database.md
Original file line number Diff line number Diff line change
Expand Up @@ -304,34 +304,41 @@ final class User

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

### DTO properties
### Data transfer object properties

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).
You can store arbitrary objects directly in a `json` column when they don’t need to be part of the relational schema.

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.

```php
use Tempest\Database\IsDatabaseModel;
use Tempest\Mapper\CastWith;
use Tempest\Mapper\SerializeWith;
use Tempest\Mapper\Casters\DtoCaster;
use Tempest\Mapper\Serializers\DtoSerializer;
use Tempest\Mapper\SerializeAs;

final class DebugItem
final class User implements Authenticatable
{
use IsDatabaseModel;

/* … */

public Backtrace $backtrace,
public PrimaryKey $id;

public function __construct(
public string $email,
#[Hashed, SensitiveParameter]
public ?string $password,
public Settings $settings,
) {}
}

#[CastWith(DtoCaster::class)]
#[SerializeWith(DtoSerializer::class)]
final class Backtrace
#[SerializeAs('user_settings')]
final class Settings
{
// This object won't be considered a relation,
// but rather serialized and stored in a JSON column.
public function __construct(
public readonly Theme $theme,
public readonly bool $hide_sidebar_by_default,
) {}
}

public array $frames = [];
enum Theme: string
{
case DARK = 'dark';
case LIGHT = 'light';
case AUTO = 'auto';
}
```

Expand Down
180 changes: 169 additions & 11 deletions docs/2-features/01-mapper.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,38 @@ final readonly class AddressSerializer implements Serializer

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

### Registering casters and serializers globally

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:

```php app/AddressSerializer.php
use Tempest\Mapper\Serializer;
use Tempest\Mapper\DynamicSerializer;

final readonly class AddressSerializer implements Serializer, DynamicSerializer
{
public static function accepts(PropertyReflector|TypeReflector $input): bool
{
$type = $input instanceof PropertyReflector
? $input->getType()
: $input;

return $type->matches(Address::class);
}

public function serialize(mixed $input): array|string
{
if (! $input instanceof Address) {
throw new CannotSerializeValue(Address::class);
}

return $input->toArray();
}
}
```

Dynamic serializers and casters will automatically be discovered by Tempest.

### Specifying casters or serializers for properties

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.
Expand All @@ -218,21 +250,147 @@ final class User

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

### Registering casters and serializers globally
## Mapping contexts

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

### Using contexts

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.

```php
use Tempest\Mapper\Casters\CasterFactory;
use Tempest\Mapper\Serializers\SerializerFactory;
use App\SerializationContext;
use function Tempest\Mapper\map;

// Register a caster globally for a specific type
$container->get(CasterFactory::class)
->addCaster(Address::class, AddressCaster::class);
$json = map($book)
->in(SerializationContext::API)
->toJson();
```

To create a caster or serializer that only applies in a specific context, use the {b`#[Tempest\Mapper\Attributes\Context]`} attribute on your class:

```php
use Tempest\DateTime\DateTime;
use Tempest\DateTime\FormatPattern;
use Tempest\Mapper\Attributes\Context;
use Tempest\Mapper\Serializer;
use Tempest\Mapper\DynamicSerializer;

// Register a serializer globally for a specific type
$container->get(SerializerFactory::class)
->addSerializer(Address::class, AddressSerializer::class);
#[Context(SerializationContext::API)]
final readonly class ApiDateSerializer implements Serializer, DynamicSerializer
{
public static function accepts(PropertyReflector|TypeReflector $input): bool
{
$type = $input instanceof PropertyReflector
? $input->getType()
: $input;

return $type->matches(DateTime::class);
}

public function serialize(mixed $input): string
{
return $input->format(FormatPattern::ISO8601);
}
}
```

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

### Injecting context into casters and serializers

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.

```php
use Tempest\Mapper\Context;
use Tempest\Mapper\Serializer;

#[Context(DatabaseContext::class)]
final class BooleanSerializer implements Serializer, DynamicSerializer
{
public function __construct(
private DatabaseContext $context,
) {}

public static function accepts(PropertyReflector|TypeReflector $type): bool
{
$type = $type instanceof PropertyReflector
? $type->getType()
: $type;

return $type->getName() === 'bool' || $type->getName() === 'boolean';
}

public function serialize(mixed $input): string
{
return match ($this->context->dialect) {
DatabaseDialect::POSTGRESQL => $input ? 'true' : 'false',
default => $input ? '1' : '0',
};
}
}
```

## Configurable casters and serializers

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.

Implement the {b`\Tempest\Mapper\ConfigurableCaster`} or {b`\Tempest\Mapper\ConfigurableSerializer`} interface to create casters/serializers that are configured per property:

```php
use Tempest\Mapper\Caster;
use Tempest\Mapper\ConfigurableCaster;
use Tempest\Mapper\Context;
use Tempest\Mapper\DynamicCaster;
use Tempest\Reflection\PropertyReflector;

final readonly class EnumCaster implements Caster, DynamicCaster, ConfigurableCaster
{
/**
* @param class-string<UnitEnum> $enum
*/
public function __construct(
private string $enum,
) {}

public static function accepts(PropertyReflector|TypeReflector $input): bool
{
$type = $input instanceof PropertyReflector
? $input->getType()
: $input;

return $type->matches(UnitEnum::class);
}

public static function configure(PropertyReflector $property, Context $context): self
{
// Create a new instance configured for this specific property
return new self(enum: $property->getType()->getName());
}

public function cast(mixed $input): ?object
{
if ($input === null) {
return null;
}

// Use the configured enum class
return $this->enum::from($input);
}
}
```

The `configure()` method receives the property being mapped and the current context, allowing you to create a caster instance tailored to that specific property.

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.

### When to use configurable casters and serializers

Use configurable casters and serializers when:

- The caster/serializer behavior depends on the specific property type (e.g., enum class, object class)
- You need access to property attributes or metadata
- Different properties of the same base type require different handling
- You want to avoid creating many similar caster/serializer classes

For simple, static behavior that doesn't depend on property information, regular casters and serializers are sufficient.
2 changes: 1 addition & 1 deletion mago.toml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
php-version = "8.4.0"
php-version = "8.5.0"

[source]
paths = ["src", "packages", "tests"]
Expand Down
11 changes: 10 additions & 1 deletion packages/auth/tests/OAuthTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use League\OAuth2\Client\Provider\Instagram;
use League\OAuth2\Client\Provider\LinkedIn;
use League\OAuth2\Client\Provider\ResourceOwnerInterface;
use PHPUnit\Framework\Attributes\Before;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Tempest\Auth\OAuth\Config\AppleOAuthConfig;
Expand All @@ -23,6 +24,7 @@
use Tempest\Auth\OAuth\Config\LinkedInOAuthConfig;
use Tempest\Auth\OAuth\OAuthClientInitializer;
use Tempest\Auth\OAuth\OAuthUser;
use Tempest\Container\Container;
use Tempest\Container\GenericContainer;
use Tempest\Mapper\MapperConfig;
use Tempest\Mapper\Mappers\ArrayToObjectMapper;
Expand All @@ -31,13 +33,20 @@
final class OAuthTest extends TestCase
{
private GenericContainer $container {
get => $this->container ??= new GenericContainer()->addInitializer(OAuthClientInitializer::class);
get => $this->container ??= new GenericContainer();
}

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

#[Before]
public function before(): void
{
$this->container->singleton(Container::class, $this->container);
$this->container->addInitializer(OAuthClientInitializer::class);
}

#[Test]
public function github_oauth_config(): void
{
Expand Down
6 changes: 1 addition & 5 deletions packages/container/src/GenericContainer.php
Original file line number Diff line number Diff line change
Expand Up @@ -177,11 +177,7 @@ public function get(string $className, null|string|UnitEnum $tag = null, mixed .
{
$this->resolveChain();

$dependency = $this->resolve(
className: $className,
tag: $tag,
params: $params,
);
$dependency = $this->resolve($className, $tag, ...$params);

$this->stopChain();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

use Closure;
use Tempest\Database\Builder\ModelInspector;
use Tempest\Database\Database;
use Tempest\Database\DatabaseContext;
use Tempest\Database\Exceptions\HasManyRelationCouldNotBeInsterted;
use Tempest\Database\Exceptions\HasOneRelationCouldNotBeInserted;
use Tempest\Database\Exceptions\ModelDidNotHavePrimaryColumn;
Expand All @@ -22,6 +24,7 @@
use Tempest\Support\Str\ImmutableString;

use function Tempest\Database\inspect;
use function Tempest\get;
use function Tempest\Support\str;

/**
Expand All @@ -40,6 +43,14 @@ final class InsertQueryBuilder implements BuildsQuery

public ModelInspector $model;

private Database $database {
get => get(Database::class, $this->onDatabase);
}

private DatabaseContext $context {
get => new DatabaseContext(dialect: $this->database->dialect);
}

/**
* @param class-string<TModel>|string|TModel $model
*/
Expand Down Expand Up @@ -469,7 +480,10 @@ private function serializeValue(PropertyReflector $property, mixed $value): mixe
return null;
}

return $this->serializerFactory->forProperty($property)?->serialize($value) ?? $value;
return $this->serializerFactory
->in($this->context)
->forProperty($property)
?->serialize($value) ?? $value;
}

private function serializeIterableValue(string $key, mixed $value): mixed
Expand Down
Loading