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. 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; } 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, ); } 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. * 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 @@ +getPublicProperties() as $property) { + if ($property->hasAttribute(Hidden::class)) { + continue; + } + $propertyName = $this->resolvePropertyName($property); $propertyValue = $this->resolvePropertyValue($property, $from); $mappedProperties[$propertyName] = $propertyValue; diff --git a/tests/Integration/Database/ModelInspector/HiddenTest.php b/tests/Integration/Database/ModelInspector/HiddenTest.php new file mode 100644 index 000000000..80034e65c --- /dev/null +++ b/tests/Integration/Database/ModelInspector/HiddenTest.php @@ -0,0 +1,153 @@ +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; +}