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>
*/