Skip to content
Open
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
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;
}