From c20477ac4f5d1a2e49d1c21bbdeab89340fd7c5c Mon Sep 17 00:00:00 2001 From: Mark Date: Wed, 28 Jan 2026 02:43:31 +0100 Subject: [PATCH 1/8] feat(mapper): add Hidden attribute --- packages/mapper/src/Hidden.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 packages/mapper/src/Hidden.php diff --git a/packages/mapper/src/Hidden.php b/packages/mapper/src/Hidden.php new file mode 100644 index 000000000..9c9e94f05 --- /dev/null +++ b/packages/mapper/src/Hidden.php @@ -0,0 +1,15 @@ + Date: Wed, 28 Jan 2026 02:43:37 +0100 Subject: [PATCH 2/8] feat(mapper): skip hidden properties in serialization --- packages/mapper/src/Mappers/ObjectToArrayMapper.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/mapper/src/Mappers/ObjectToArrayMapper.php b/packages/mapper/src/Mappers/ObjectToArrayMapper.php index ef68537f1..f72f411d2 100644 --- a/packages/mapper/src/Mappers/ObjectToArrayMapper.php +++ b/packages/mapper/src/Mappers/ObjectToArrayMapper.php @@ -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; @@ -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; From 8870eb295484f51163774d96aa59bebcb7f190d0 Mon Sep 17 00:00:00 2001 From: Mark Date: Wed, 28 Jan 2026 02:43:44 +0100 Subject: [PATCH 3/8] feat(database): exclude hidden fields from SELECT --- packages/database/src/Builder/ModelInspector.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/database/src/Builder/ModelInspector.php b/packages/database/src/Builder/ModelInspector.php index 7b349e3a8..d39dab76d 100644 --- a/packages/database/src/Builder/ModelInspector.php +++ b/packages/database/src/Builder/ModelInspector.php @@ -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; @@ -397,6 +398,10 @@ public function getSelectFields(): ImmutableArray continue; } + if ($property->hasAttribute(Hidden::class)) { + continue; + } + if ($property->getType()->equals(PrimaryKey::class)) { continue; } From b1f18c5c67d1d7d35d2b839fcaf08ea61ee931d9 Mon Sep 17 00:00:00 2001 From: Mark Date: Wed, 28 Jan 2026 02:43:49 +0100 Subject: [PATCH 4/8] feat(database): add include() method for hidden fields --- .../QueryBuilders/SelectQueryBuilder.php | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php index b348653c5..202d91afa 100644 --- a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php @@ -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 + */ + 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. * From 97b22989027544ff31e665bc67af626ecd1218dc Mon Sep 17 00:00:00 2001 From: Mark Date: Wed, 28 Jan 2026 02:43:54 +0100 Subject: [PATCH 5/8] fix(database): deduplicate fields in select() --- packages/database/src/Builder/QueryBuilders/QueryBuilder.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/database/src/Builder/QueryBuilders/QueryBuilder.php b/packages/database/src/Builder/QueryBuilders/QueryBuilder.php index 9394d6f87..1acd8c54f 100644 --- a/packages/database/src/Builder/QueryBuilders/QueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/QueryBuilder.php @@ -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, ); } From 7f7e959201c576f4d662c9f82ee79927c2c93d34 Mon Sep 17 00:00:00 2001 From: Mark Date: Wed, 28 Jan 2026 02:44:00 +0100 Subject: [PATCH 6/8] test(database): add Hidden attribute integration tests --- .../Database/ModelInspector/HiddenTest.php | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 tests/Integration/Database/ModelInspector/HiddenTest.php diff --git a/tests/Integration/Database/ModelInspector/HiddenTest.php b/tests/Integration/Database/ModelInspector/HiddenTest.php new file mode 100644 index 000000000..e41e03942 --- /dev/null +++ b/tests/Integration/Database/ModelInspector/HiddenTest.php @@ -0,0 +1,144 @@ +getSelectFields(); + + $this->assertContains('id', $selectFields->toArray()); + $this->assertContains('name', $selectFields->toArray()); + $this->assertNotContains('password', $selectFields->toArray()); + $this->assertNotContains('secret', $selectFields->toArray()); + } + + public function test_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']); + } + + public function test_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); + } + + public function test_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); + } + + public function test_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); + } + + public function test_include_with_already_selected_field_is_ignored(): void + { + $sql = HiddenTestModel::select() + ->include('name') + ->compile() + ->toString(); + + $this->assertSame(2, substr_count($sql, 'name')); + } + + public function test_include_with_duplicate_selected_field_is_filtered(): void + { + $sql = HiddenTestModel::select() + ->include('password', 'password') + ->compile() + ->toString(); + + $this->assertSame(2, substr_count($sql, 'password')); + } + + public function test_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; +} From 9b8116816e6b11dae18ed40169ead159a5589ce3 Mon Sep 17 00:00:00 2001 From: Mark Date: Wed, 28 Jan 2026 04:24:13 +0100 Subject: [PATCH 7/8] docs: add Hidden attribute documentation --- docs/1-essentials/03-database.md | 51 ++++++++++++++++++++++++++++++++ docs/2-features/01-mapper.md | 34 +++++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/docs/1-essentials/03-database.md b/docs/1-essentials/03-database.md index 793273d15..fdaa0dd38 100644 --- a/docs/1-essentials/03-database.md +++ b/docs/1-essentials/03-database.md @@ -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. diff --git a/docs/2-features/01-mapper.md b/docs/2-features/01-mapper.md index 342165552..b33967c1a 100644 --- a/docs/2-features/01-mapper.md +++ b/docs/2-features/01-mapper.md @@ -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. From 4f291a2c3cfbfd9541ab6c00faa524feae90b769 Mon Sep 17 00:00:00 2001 From: Mark Date: Thu, 29 Jan 2026 11:03:36 +0100 Subject: [PATCH 8/8] refactor(database): use #[Test] attributes --- .../Database/ModelInspector/HiddenTest.php | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/tests/Integration/Database/ModelInspector/HiddenTest.php b/tests/Integration/Database/ModelInspector/HiddenTest.php index e41e03942..80034e65c 100644 --- a/tests/Integration/Database/ModelInspector/HiddenTest.php +++ b/tests/Integration/Database/ModelInspector/HiddenTest.php @@ -2,6 +2,7 @@ namespace Tests\Tempest\Integration\Database\ModelInspector; +use PHPUnit\Framework\Attributes\Test; use Tempest\Database\IsDatabaseModel; use Tempest\Database\PrimaryKey; use Tempest\Database\Table; @@ -14,7 +15,8 @@ final class HiddenTest extends FrameworkIntegrationTestCase { - public function test_hidden_property_is_excluded_from_select_fields(): void + #[Test] + public function hidden_property_is_excluded_from_select_fields(): void { $model = inspect(HiddenTestModel::class); $selectFields = $model->getSelectFields(); @@ -25,7 +27,8 @@ public function test_hidden_property_is_excluded_from_select_fields(): void $this->assertNotContains('secret', $selectFields->toArray()); } - public function test_hidden_property_is_included_in_property_values(): void + #[Test] + public function hidden_property_is_included_in_property_values(): void { $instance = new HiddenTestModel(); $instance->id = new PrimaryKey(1); @@ -44,7 +47,8 @@ public function test_hidden_property_is_included_in_property_values(): void $this->assertSame('my-secret', $propertyValues['secret']); } - public function test_hidden_property_is_excluded_from_serialization(): void + #[Test] + public function hidden_property_is_excluded_from_serialization(): void { $object = new HiddenTestModel(); $object->id = new PrimaryKey(1); @@ -60,7 +64,8 @@ public function test_hidden_property_is_excluded_from_serialization(): void $this->assertArrayNotHasKey('secret', $array); } - public function test_hidden_property_is_excluded_from_json_serialization(): void + #[Test] + public function hidden_property_is_excluded_from_json_serialization(): void { $object = new HiddenTestModel(); $object->id = new PrimaryKey(1); @@ -75,7 +80,8 @@ public function test_hidden_property_is_excluded_from_json_serialization(): void $this->assertStringNotContainsString('secret', $json); } - public function test_include_adds_hidden_fields_to_query(): void + #[Test] + public function include_adds_hidden_fields_to_query(): void { $sql = HiddenTestModel::select()->compile()->toString(); @@ -99,7 +105,8 @@ public function test_include_adds_hidden_fields_to_query(): void $this->assertStringContainsString('secret', $sql); } - public function test_include_with_already_selected_field_is_ignored(): void + #[Test] + public function include_with_already_selected_field_is_ignored(): void { $sql = HiddenTestModel::select() ->include('name') @@ -109,7 +116,8 @@ public function test_include_with_already_selected_field_is_ignored(): void $this->assertSame(2, substr_count($sql, 'name')); } - public function test_include_with_duplicate_selected_field_is_filtered(): void + #[Test] + public function include_with_duplicate_selected_field_is_filtered(): void { $sql = HiddenTestModel::select() ->include('password', 'password') @@ -119,7 +127,8 @@ public function test_include_with_duplicate_selected_field_is_filtered(): void $this->assertSame(2, substr_count($sql, 'password')); } - public function test_select_with_duplicate_selected_field_is_filtered(): void + #[Test] + public function select_with_duplicate_selected_field_is_filtered(): void { $sql = query(HiddenTestModel::class)->select('name', 'name')->include('name')->compile()->toString();