Skip to content
51 changes: 51 additions & 0 deletions docs/1-essentials/03-database.md
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,57 @@ final class Book
}
```

### Hidden properties

Sensitive properties can be marked with the {b`#[Tempest\Mapper\Hidden]`} attribute to exclude them from SELECT queries. This is useful for properties like passwords, API keys, or other sensitive data that should not be fetched or exposed by default.

```php
use Tempest\Database\IsDatabaseModel;
use Tempest\Mapper\Hidden;

final class User
{
use IsDatabaseModel;

public string $email;

#[Hidden]
public string $password;

#[Hidden]
public ?string $apiKey = null;
}
```

Hidden properties are still included in INSERT and UPDATE queries, allowing them to be persisted to the database.

To explicitly include hidden fields in a query, use the `include()` method on the query builder:

```php
// Password is not included in the query
$user = User::select()->where('email', $email)->first();

// Password is explicitly included
$user = User::select()
->include('password')
->where('email', $email)
->first();

// Multiple hidden fields can be included
$user = User::select()
->include('password', 'apiKey')
->where('email', $email)
->first();
```

:::info
Unlike {b`#[Virtual]`} which marks computed properties that don't exist in the database, {b`#[Hidden]`} is for real database columns that should be protected from accidental exposure.
:::

:::info
The {b`#[Hidden]`} attribute also excludes properties from serialization. See the [mapper documentation](../2-features/01-mapper.md#hiding-properties-from-serialization) for more information.
:::

### The `IsDatabaseModel` trait

The {b`Tempest\Database\IsDatabaseModel`} trait provides an active record pattern. This trait enables database interaction via static methods on the model class itself.
Expand Down
34 changes: 34 additions & 0 deletions docs/2-features/01-mapper.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,40 @@ $array = map($book)->toArray();
$json = map($book)->toJson();
```

### Hiding properties from serialization

Properties marked with the {b`#[Tempest\Mapper\Hidden]`} attribute are excluded from serialization. This is useful for sensitive data like passwords or API keys that should never be exposed in arrays or JSON responses.

```php
use Tempest\Mapper\Hidden;

final class User
{
public string $email;

#[Hidden]
public string $password;
}
```

When serializing, hidden properties are automatically excluded:

```php
$user = new User();
$user->email = 'user@example.com';
$user->password = 'secret';

$array = map($user)->toArray();
// ['email' => 'user@example.com']

$json = map($user)->toJson();
// {"email":"user@example.com"}
```

:::info
The {b`#[Hidden]`} attribute also excludes properties from database SELECT queries. See the [database documentation](../1-essentials/03-database.md#hidden-properties) for more information.
:::

### Overriding field names

When mapping from an array to an object, Tempest uses the property names of the target class to map the data. If a property name doesn't match a key in the source array, use the {b`#[Tempest\Mapper\MapFrom]`} attribute to specify the source key to map to the property.
Expand Down
5 changes: 5 additions & 0 deletions packages/database/src/Builder/ModelInspector.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use Tempest\Database\Table;
use Tempest\Database\Uuid;
use Tempest\Database\Virtual;
use Tempest\Mapper\Hidden;
use Tempest\Mapper\SerializeAs;
use Tempest\Mapper\SerializeWith;
use Tempest\Reflection\ClassReflector;
Expand Down Expand Up @@ -397,6 +398,10 @@ public function getSelectFields(): ImmutableArray
continue;
}

if ($property->hasAttribute(Hidden::class)) {
continue;
}

if ($property->getType()->equals(PrimaryKey::class)) {
continue;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ public function select(string ...$columns): SelectQueryBuilder
{
return new SelectQueryBuilder(
model: $this->model,
fields: $columns !== [] ? arr($columns) : null,
fields: $columns !== [] ? arr($columns)->unique() : null,
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,28 @@ public function with(string ...$relations): self
return $this;
}

/**
* Includes additional fields in the query. Useful for including hidden fields.
*
* @return self<TModel>
*/
public function include(string ...$fields): self
{
$tableName = $this->model->getTableName();
$existingFields = $this->model->getSelectFields()->toArray();

foreach ($fields as $field) {
if (in_array($field, $existingFields, true)) {
continue;
}

$existingFields[] = $field;
$this->select->fields[] = new FieldStatement("{$tableName}.{$field}")->withAlias();
}

return $this;
}

/**
* Adds a raw SQL statement to the query.
*
Expand Down
15 changes: 15 additions & 0 deletions packages/mapper/src/Hidden.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace Tempest\Mapper;

use Attribute;

/**
* Hidden properties are excluded from SELECT queries and serialization.
*/
#[Attribute(Attribute::TARGET_PROPERTY)]
final readonly class Hidden
{
}
5 changes: 5 additions & 0 deletions packages/mapper/src/Mappers/ObjectToArrayMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use JsonSerializable;
use Tempest\Mapper\Context;
use Tempest\Mapper\Hidden;
use Tempest\Mapper\Mapper;
use Tempest\Mapper\MapTo;
use Tempest\Mapper\SerializerFactory;
Expand Down Expand Up @@ -38,6 +39,10 @@ public function map(mixed $from, mixed $to): mixed
$mappedProperties = [];

foreach ($class->getPublicProperties() as $property) {
if ($property->hasAttribute(Hidden::class)) {
continue;
}

$propertyName = $this->resolvePropertyName($property);
$propertyValue = $this->resolvePropertyValue($property, $from);
$mappedProperties[$propertyName] = $propertyValue;
Expand Down
153 changes: 153 additions & 0 deletions tests/Integration/Database/ModelInspector/HiddenTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
<?php

namespace Tests\Tempest\Integration\Database\ModelInspector;

use PHPUnit\Framework\Attributes\Test;
use Tempest\Database\IsDatabaseModel;
use Tempest\Database\PrimaryKey;
use Tempest\Database\Table;
use Tempest\Mapper\Hidden;
use Tests\Tempest\Integration\FrameworkIntegrationTestCase;

use function Tempest\Database\inspect;
use function Tempest\Database\query;
use function Tempest\Mapper\map;

final class HiddenTest extends FrameworkIntegrationTestCase
{
#[Test]
public function hidden_property_is_excluded_from_select_fields(): void
{
$model = inspect(HiddenTestModel::class);
$selectFields = $model->getSelectFields();

$this->assertContains('id', $selectFields->toArray());
$this->assertContains('name', $selectFields->toArray());
$this->assertNotContains('password', $selectFields->toArray());
$this->assertNotContains('secret', $selectFields->toArray());
}

#[Test]
public function hidden_property_is_included_in_property_values(): void
{
$instance = new HiddenTestModel();
$instance->id = new PrimaryKey(1);
$instance->name = 'John';
$instance->password = 'secret123'; // @mago-expect lint:no-literal-password
$instance->secret = 'my-secret'; // @mago-expect lint:no-literal-password

$model = inspect($instance);
$propertyValues = $model->getPropertyValues();

$this->assertArrayHasKey('name', $propertyValues);
$this->assertArrayHasKey('password', $propertyValues);
$this->assertArrayHasKey('secret', $propertyValues);
$this->assertSame('John', $propertyValues['name']);
$this->assertSame('secret123', $propertyValues['password']);
$this->assertSame('my-secret', $propertyValues['secret']);
}

#[Test]
public function hidden_property_is_excluded_from_serialization(): void
{
$object = new HiddenTestModel();
$object->id = new PrimaryKey(1);
$object->name = 'John';
$object->password = 'secret123'; // @mago-expect lint:no-literal-password
$object->secret = 'my-secret'; // @mago-expect lint:no-literal-password

$array = map($object)->toArray();

$this->assertArrayHasKey('id', $array);
$this->assertArrayHasKey('name', $array);
$this->assertArrayNotHasKey('password', $array);
$this->assertArrayNotHasKey('secret', $array);
}

#[Test]
public function hidden_property_is_excluded_from_json_serialization(): void
{
$object = new HiddenTestModel();
$object->id = new PrimaryKey(1);
$object->name = 'John';
$object->password = 'secret123'; // @mago-expect lint:no-literal-password
$object->secret = 'my-secret'; // @mago-expect lint:no-literal-password

$json = map($object)->toJson();

$this->assertStringContainsString('"name":"John"', $json);
$this->assertStringNotContainsString('password', $json);
$this->assertStringNotContainsString('secret', $json);
}

#[Test]
public function include_adds_hidden_fields_to_query(): void
{
$sql = HiddenTestModel::select()->compile()->toString();

$this->assertStringNotContainsString('password', $sql);
$this->assertStringNotContainsString('secret', $sql);

$sql = HiddenTestModel::select()
->include('password')
->compile()
->toString();

$this->assertStringContainsString('password', $sql);
$this->assertStringNotContainsString('secret', $sql);

$sql = HiddenTestModel::select()
->include('password', 'secret')
->compile()
->toString();

$this->assertStringContainsString('password', $sql);
$this->assertStringContainsString('secret', $sql);
}

#[Test]
public function include_with_already_selected_field_is_ignored(): void
{
$sql = HiddenTestModel::select()
->include('name')
->compile()
->toString();

$this->assertSame(2, substr_count($sql, 'name'));
}

#[Test]
public function include_with_duplicate_selected_field_is_filtered(): void
{
$sql = HiddenTestModel::select()
->include('password', 'password')
->compile()
->toString();

$this->assertSame(2, substr_count($sql, 'password'));
}

#[Test]
public function select_with_duplicate_selected_field_is_filtered(): void
{
$sql = query(HiddenTestModel::class)->select('name', 'name')->include('name')->compile()->toString();

$this->assertSame(1, substr_count($sql, 'name'));
}
}

#[Table('hidden_test')]
final class HiddenTestModel
{
use IsDatabaseModel;

public PrimaryKey $id;

public string $name;

#[Hidden]
public string $password;

#[Hidden]
public string $secret;
}