diff --git a/README.md b/README.md index 2eaaf06..227896b 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ This bundle comes with several attributes that you can use to add mapping to you - Use it as a Class attribute if you don't intend to use the property yourself ([see example](tests/Examples/Valid/Complex/ProductDTO.php)). It will then only be used internally and not be mapped to your DTO. - Use it as a Property attribute if you have some use for it ([see example](tests/Examples/Valid/Complex/CustomerDTO.php)). - Specify the mapped property name directly on the attribute ([see example](tests/Examples/Valid/Complex/InvoiceDTO.php)). This is mandatory when used as a Class attribute. - - Specify the mapped property name separately with the `InboundProperty` attribute, Doctrine-style ([see example](tests/Examples/Valid/ReferenceArray/AuthorDTO.php)). + - Specify the mapped property name separately with the `#[Scalar]` attribute ([see example](tests/Examples/Valid/ReferenceArray/AuthorDTO.php)). - `#[Scalar("mapped_property_name")]`: The column `mapped_property_name` of your result set will be mapped to a scalar property of your DTO (the value of the first row will be used). This is optional if your DTO's property names are already matching the result set ([see example](tests/Examples/Valid/WithoutAttributeDTO.php)). - `#[ReferenceArray(NestedDTO::class)]`: An array of `NestedDTO` will be created using the mapping information contained in `NestedDTO`. - `#[ScalarArray("mapped_property_name")]` The column `mapped_property_name` of your result set will be mapped as an array of scalar properties, such as IDs ([see example](tests/Examples/Valid/ScalarArray/ScalarArrayDTO.php)). @@ -85,13 +85,16 @@ This bundle comes with several attributes that you can use to add mapping to you If the mapping between result columns and DTO properties is consistent, you can use the `#[NameTransformation]` class attribute instead of adding `#[Scalar(...)]` to each property: -- `#[NameTransformation(removePrefix: 'foo_')]`: the columns named `foo_bar` and `foo_baz` will be mapped to - `$bar` and `$baz` properties of a DTO class. -- `#[NameTransformation(camelize: true)]`: the column with snake-case name `foo_bar` will be mapped to `$fooBar` property. -- If both of the above rules are enabled, then `foo_bar_baz` result column will be mapped to `$barBaz` property. +- `#[NameTransformation(columnPrefix: 'foo_')]`: adds the prefix `foo_` to property names when looking up columns. + For example, properties `$bar` and `$baz` will look for columns `foo_bar` and `foo_baz`. +- `#[NameTransformation(snakeCaseColumns: true)]`: converts camelCase/PascalCase property names to snake_case when looking up columns. + For example, property `$fooBar` or `$FooBar` will look for column `foo_bar`. +- If both of the above rules are enabled, then property `$barBaz` will look for column `foo_bar_baz`. - Adding a `#[Scalar]` attribute or an `#[Identifier]` attribute with explicitly given name to a property will override mapping set up on class level. +**Note:** The old parameter names `removePrefix` and `camelize` are still supported for backward compatibility but are deprecated in favor of `columnPrefix` and `snakeCaseColumns`. + ### Hydrating nested DTOs diff --git a/src/FlatMapper.php b/src/FlatMapper.php index ae2440d..79066bf 100644 --- a/src/FlatMapper.php +++ b/src/FlatMapper.php @@ -165,14 +165,14 @@ private function createMappingRecursive(string $dtoClassName, ?array& $objectIde private function transformPropertyName(string $propertyName, NameTransformation $transformation): string { - if ($transformation->camelize) { + if ($transformation->snakeCaseColumns) { $propertyName = strtolower(preg_replace( ['/([A-Z]+)([A-Z][a-z])/', '/([a-z\d])([A-Z])/'], '\1_\2', $propertyName ) ?? $propertyName); } - return $transformation->removePrefix . $propertyName; + return $transformation->columnPrefix . $propertyName; } /** diff --git a/src/Mapping/NameTransformation.php b/src/Mapping/NameTransformation.php index 2d8bab3..c68c76b 100644 --- a/src/Mapping/NameTransformation.php +++ b/src/Mapping/NameTransformation.php @@ -8,9 +8,21 @@ #[Attribute(Attribute::TARGET_CLASS)] final readonly class NameTransformation { + public readonly string $columnPrefix; + public readonly bool $snakeCaseColumns; + public function __construct( - public string $removePrefix = '', - public bool $camelize = false + // New parameter names (recommended) + string $columnPrefix = '', + bool $snakeCaseColumns = false, + + // Old parameter names + /** @deprecated Use $columnPrefix instead */ + string $removePrefix = '', + /** @deprecated Use $snakeCaseColumns instead */ + bool $camelize = false ) { + $this->columnPrefix = $columnPrefix ?: $removePrefix; + $this->snakeCaseColumns = $snakeCaseColumns ?: $camelize; } } diff --git a/src/PixelshapedFlatMapperBundle.php b/src/PixelshapedFlatMapperBundle.php index baea2a0..f76e3ce 100644 --- a/src/PixelshapedFlatMapperBundle.php +++ b/src/PixelshapedFlatMapperBundle.php @@ -29,7 +29,7 @@ public function loadExtension(array $config, ContainerConfigurator $container, C public function configure(DefinitionConfigurator $definition): void { - $definition->rootNode() // @phpstan-ignore-line + $definition->rootNode() ->children() ->booleanNode('validate_mapping')->defaultTrue()->end() ->scalarNode('cache_service')->defaultNull()->end() diff --git a/tests/Examples/Invalid/NameTransformation/InvalidNameTransformationDTO.php b/tests/Examples/Invalid/NameTransformation/InvalidNameTransformationDTO.php new file mode 100644 index 0000000..5922e85 --- /dev/null +++ b/tests/Examples/Invalid/NameTransformation/InvalidNameTransformationDTO.php @@ -0,0 +1,19 @@ +createMapping(AuthorDTO::class); } + public function testCreateMappingWithCacheServiceMissExecutesCallback(): void + { + $flatMapper = new FlatMapper(); + + $cacheInterface = $this->createMock(CacheInterface::class); + $cacheInterface->expects($this->once())->method('get')->willReturnCallback( + function (string $key, callable $callback) { + return $callback(); + } + ); + + $flatMapper->setCacheService($cacheInterface); + $flatMapper->createMapping(AuthorDTO::class); + } + public function testCreateMappingWrongClassNameAsserts(): void { $this->expectException(MappingCreationException::class); @@ -103,4 +119,11 @@ public function testCreateMappingWithEmptyClassIdentifierAsserts(): void $this->expectExceptionMessageMatches("/The Identifier attribute cannot be used without a property name when used as a Class attribute/"); (new FlatMapper())->createMapping(RootDTOWithEmptyClassIdentifier::class); } + + public function testCreateMappingWithInvalidNameTransformationAsserts(): void + { + $this->expectException(MappingCreationException::class); + $this->expectExceptionMessageMatches("/Invalid NameTransformation attribute/"); + (new FlatMapper())->createMapping(InvalidNameTransformationDTO::class); + } } diff --git a/tests/FlatMapperTest.php b/tests/FlatMapperTest.php index df65d13..71e4566 100644 --- a/tests/FlatMapperTest.php +++ b/tests/FlatMapperTest.php @@ -12,6 +12,13 @@ use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\Complex\CustomerDTO; use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\Complex\InvoiceDTO; use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\Complex\ProductDTO; +use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\NameTransformation\AccountDTO; +use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\NameTransformation\CarDTO; +use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\NameTransformation\ItemDTO; +use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\NameTransformation\LegacyDTO; +use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\NameTransformation\OrderDTO; +use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\NameTransformation\PersonDTO; +use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\NameTransformation\ProductDTO as NameTransformationProductDTO; use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\ReferenceArray\AuthorDTO; use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\ReferenceArray\BookDTO; use Pixelshaped\FlatMapperBundle\Tests\Examples\Valid\ScalarArray\ScalarArrayDTO; @@ -240,6 +247,217 @@ public function testMapNestedDTOsWithClassAttributes(): void ); } + public function testMapWithNameTransformationRemovePrefix(): void + { + $results = [ + ['person_id' => 1, 'person_name' => 'John Doe', 'person_age' => 30], + ['person_id' => 2, 'person_name' => 'Jane Smith', 'person_age' => 25], + ]; + + $flatMapperResults = ((new FlatMapper())->map(PersonDTO::class, $results)); + + $personDto1 = new PersonDTO(1, "John Doe", 30); + $personDto2 = new PersonDTO(2, "Jane Smith", 25); + $handmadeResult = [1 => $personDto1, 2 => $personDto2]; + + $this->assertSame( + var_export($flatMapperResults, true), + var_export($handmadeResult, true) + ); + } + + public function testMapWithNameTransformationWhenDatasetMissingPrefix(): void + { + $this->expectException(MappingException::class); + $this->expectExceptionMessageMatches('/Data does not contain required property: person_name/'); + + $results = [ + ['person_id' => 1, 'name' => 'John Doe', 'age' => 30], // Missing person_ prefix + ['person_id' => 2, 'name' => 'Jane Smith', 'age' => 25], + ]; + + ((new FlatMapper())->map(PersonDTO::class, $results)); + } + + public function testMapWithNameTransformationCamelize(): void + { + $results = [ + ['product_id' => 1, 'product_name' => 'Widget', 'product_price' => 19.99], + ['product_id' => 2, 'product_name' => 'Gadget', 'product_price' => 29.99], + ]; + + $flatMapperResults = ((new FlatMapper())->map(NameTransformationProductDTO::class, $results)); + + $productDto1 = new NameTransformationProductDTO(1, "Widget", 19.99); + $productDto2 = new NameTransformationProductDTO(2, "Gadget", 29.99); + $handmadeResult = [1 => $productDto1, 2 => $productDto2]; + + $this->assertSame( + var_export($flatMapperResults, true), + var_export($handmadeResult, true) + ); + } + + public function testMapWithNameTransformationCamelizeActualCamelCase(): void + { + $results = [ + ['item_id' => 1, 'item_name' => 'Widget', 'item_price' => 19.99], + ['item_id' => 2, 'item_name' => 'Gadget', 'item_price' => 29.99], + ]; + + $flatMapperResults = ((new FlatMapper())->map(ItemDTO::class, $results)); + + $itemDto1 = new ItemDTO(1, "Widget", 19.99); + $itemDto2 = new ItemDTO(2, "Gadget", 29.99); + $handmadeResult = [1 => $itemDto1, 2 => $itemDto2]; + + $this->assertSame( + var_export($flatMapperResults, true), + var_export($handmadeResult, true) + ); + } + + public function testMapWithNameTransformationCamelizeWhenDatasetNotSnakeCase(): void + { + $this->expectException(MappingException::class); + $this->expectExceptionMessageMatches('/Identifier not found: product_id/'); + + $results = [ + ['ProductId' => 1, 'ProductName' => 'Widget', 'ProductPrice' => 19.99], + ['ProductId' => 2, 'ProductName' => 'Gadget', 'ProductPrice' => 29.99], + ]; + + ((new FlatMapper())->map(NameTransformationProductDTO::class, $results)); + } + + public function testMapWithNameTransformationBothPrefixAndCamelize(): void + { + $results = [ + ['order_id' => 1, 'order_customer_name' => 'John Doe', 'order_total_amount' => 99.99], + ['order_id' => 2, 'order_customer_name' => 'Jane Smith', 'order_total_amount' => 149.99], + ]; + + $flatMapperResults = ((new FlatMapper())->map(OrderDTO::class, $results)); + + $orderDto1 = new OrderDTO(1, "John Doe", 99.99); + $orderDto2 = new OrderDTO(2, "Jane Smith", 149.99); + $handmadeResult = [1 => $orderDto1, 2 => $orderDto2]; + + $this->assertSame( + var_export($flatMapperResults, true), + var_export($handmadeResult, true) + ); + } + + public function testMapWithNameTransformationBothPrefixAndCamelizeWhenDatasetIncorrect(): void + { + $this->expectException(MappingException::class); + $this->expectExceptionMessageMatches('/Data does not contain required property: order_customer_name/'); + + $results = [ + // Missing order_ prefix + ['order_id' => 1, 'customer_name' => 'John Doe', 'total_amount' => 99.99], + ['order_id' => 2, 'customer_name' => 'Jane Smith', 'total_amount' => 149.99], + ]; + + ((new FlatMapper())->map(OrderDTO::class, $results)); + } + + public function testMapWithNameTransformationIdentifierAttributeTakesPrecedence(): void + { + // CarDTO has NameTransformation(removePrefix: 'car_', camelize: true) + // But the Identifier attribute explicitly specifies 'vehicle_id' + // This tests that Identifier attribute takes precedence over NameTransformation + $results = [ + ['vehicle_id' => 1, 'car_model' => 'Civic', 'car_brand' => 'Honda'], + ['vehicle_id' => 2, 'car_model' => 'Corolla', 'car_brand' => 'Toyota'], + ]; + + $flatMapperResults = ((new FlatMapper())->map(CarDTO::class, $results)); + + $carDto1 = new CarDTO(1, "Civic", "Honda"); + $carDto2 = new CarDTO(2, "Corolla", "Toyota"); + $handmadeResult = [1 => $carDto1, 2 => $carDto2]; + + $this->assertSame( + var_export($flatMapperResults, true), + var_export($handmadeResult, true) + ); + } + + public function testMapWithNameTransformationIdentifierPrecedenceWhenDatasetWrong(): void + { + $this->expectException(MappingException::class); + $this->expectExceptionMessageMatches('/Identifier not found: vehicle_id/'); + + // This should fail because the Identifier attribute expects 'vehicle_id' + // not 'car_vehicle_id' (which would be the result of applying the transformation) + $results = [ + ['car_vehicle_id' => 1, 'car_model' => 'Civic', 'car_brand' => 'Honda'], + ['car_vehicle_id' => 2, 'car_model' => 'Corolla', 'car_brand' => 'Toyota'], + ]; + + ((new FlatMapper())->map(CarDTO::class, $results)); + } + + public function testMapWithNameTransformationScalarAttributeTakesPrecedence(): void + { + // AccountDTO has NameTransformation(removePrefix: 'acc_') + // But the $balance property has #[Scalar('account_balance')] + // This tests that Scalar attribute takes precedence over NameTransformation + $results = [ + ['acc_id' => 1, 'acc_name' => 'Savings', 'account_balance' => 1000.50], + ['acc_id' => 2, 'acc_name' => 'Checking', 'account_balance' => 500.25], + ]; + + $flatMapperResults = ((new FlatMapper())->map(AccountDTO::class, $results)); + + $accountDto1 = new AccountDTO(1, "Savings", 1000.50); + $accountDto2 = new AccountDTO(2, "Checking", 500.25); + $handmadeResult = [1 => $accountDto1, 2 => $accountDto2]; + + $this->assertSame( + var_export($flatMapperResults, true), + var_export($handmadeResult, true) + ); + } + + public function testMapWithNameTransformationScalarPrecedenceWhenDatasetWrong(): void + { + $this->expectException(MappingException::class); + $this->expectExceptionMessageMatches('/Data does not contain required property: account_balance/'); + + // This should fail because the Scalar attribute expects 'account_balance' + // not 'acc_balance' (which would be the result of applying the transformation) + $results = [ + ['acc_id' => 1, 'acc_name' => 'Savings', 'acc_balance' => 1000.50], + ['acc_id' => 2, 'acc_name' => 'Checking', 'acc_balance' => 500.25], + ]; + + ((new FlatMapper())->map(AccountDTO::class, $results)); + } + + public function testMapWithNameTransformationBackwardCompatibility(): void + { + // Test that old parameter names (removePrefix, camelize) still work + // LegacyDTO uses the old parameter names + $results = [ + ['legacy_id' => 1, 'legacy_name' => 'Test'], + ['legacy_id' => 2, 'legacy_name' => 'Demo'], + ]; + + $flatMapperResults = ((new FlatMapper())->map(LegacyDTO::class, $results)); + + $legacyDto1 = new LegacyDTO(1, "Test"); + $legacyDto2 = new LegacyDTO(2, "Demo"); + $handmadeResult = [1 => $legacyDto1, 2 => $legacyDto2]; + + $this->assertSame( + var_export($flatMapperResults, true), + var_export($handmadeResult, true) + ); + } + /** * @return list> */