diff --git a/README.md b/README.md
index 227896b..330bf56 100644
--- a/README.md
+++ b/README.md
@@ -4,23 +4,54 @@
# Flat Mapper Bundle
-This bundle aims to solve the problem of building nested DTOs from flat arrays (such as database queries results).
+**Object mapper for denormalized data.** Transform flat arrays (like database JOIN results) into nested, typed DTOs without the overhead of a full ORM.
-One of its purposes is to help you create DTOs the same way you would with the Doctrine `NEW` keyword, except at depth. Other ways to do that generally imply mapping entities to DTOs which is less performant (memory and CPU wise). You can find benchmarks for this package at [Pixelshaped/flat-mapper-benchmark](https://github.com/Pixelshaped/flat-mapper-benchmark).
+## The Problem
-You can also use it to map SQL queries to objects, it has no dependency on a particular ORM.
+When you write efficient SQL JOINs, you get back **flat, denormalized rows** where parent data repeats across child records:
-## How to use?
-
-### At a glance
+```php
+// Result from: SELECT author.*, book.* FROM authors LEFT JOIN books ON books.author_id = authors.id
+$queryResults = [
+ ['author_id' => 1, 'author_name' => 'Alice Brian', 'book_id' => 1, 'book_name' => 'Travelling as a group'],
+ ['author_id' => 1, 'author_name' => 'Alice Brian', 'book_id' => 2, 'book_name' => 'My journeys'],
+ ['author_id' => 1, 'author_name' => 'Alice Brian', 'book_id' => 3, 'book_name' => 'Coding on the road'],
+ ['author_id' => 2, 'author_name' => 'Bob Schmo', 'book_id' => 4, 'book_name' => 'My best recipes'],
+];
+```
-Given a DTO such as [AuthorDTO](tests/Examples/Valid/ReferenceArray/AuthorDTO.php)
+But you want **clean, nested DTOs** for your application:
-```php
-$result = $flatMapper->map(AuthorDTO::class, $authorRepository->getAuthorsAndTheirBooks());
+```php
+[
+ AuthorDTO(
+ id: 1,
+ name: 'Alice Brian',
+ books: [
+ BookDTO(id: 1, name: 'Travelling as a group'),
+ BookDTO(id: 2, name: 'My journeys'),
+ BookDTO(id: 3, name: 'Coding on the road'),
+ ]
+ ),
+ AuthorDTO(
+ id: 2,
+ name: 'Bob Schmo',
+ books: [
+ BookDTO(id: 4, name: 'My best recipes'),
+ ]
+ ),
+]
```
-Will give you an array of `AuthorDTO` hydrated with all their `BookDTO` books (See [complete example](#complete_example)).
+**FlatMapper does this transformation automatically**, handling:
+- Deduplication (one AuthorDTO per unique author despite repeated rows)
+- Relationship reconstruction (grouping books under their authors)
+- Nested object hierarchies (DTOs containing arrays of other DTOs)
+- Type safety (strongly-typed DTOs with PHP attributes)
+
+**And it's fast.** FlatMapper outperforms Doctrine entity hydration for read operations—even without N+1 queries. See [benchmarks](https://github.com/Pixelshaped/flat-mapper-benchmark) comparing FlatMapper to Doctrine entities, partial objects, and manual mapping.
+
+## Quick Start
### Installation
@@ -28,88 +59,178 @@ Will give you an array of `AuthorDTO` hydrated with all their `BookDTO` books (S
composer require pixelshaped/flat-mapper-bundle
```
-### Configuration
+### Basic Usage
-This bundle can work without any configuration, but will display better performance if mapping validation is disabled and some cache service is autowired.
+**1. Define your DTOs with attributes:**
-If you use Symfony, you can create a configuration file to do so:
+```php
+use Pixelshaped\FlatMapperBundle\Mapping\{Identifier, Scalar, ReferenceArray};
-```yaml
-# config/pixelshaped_flat_mapper.yaml
-pixelshaped_flat_mapper:
- validate_mapping: '%kernel.debug%' # disable on prod environment
- cache_service: cache.app
+class AuthorDTO
+{
+ public function __construct(
+ #[Identifier]
+ #[Scalar('author_id')]
+ public int $id,
+
+ #[Scalar('author_name')]
+ public string $name,
+
+ #[ReferenceArray(BookDTO::class)]
+ public array $books,
+ ) {}
+}
+
+class BookDTO
+{
+ public function __construct(
+ #[Identifier('book_id')]
+ public int $id,
+
+ #[Scalar('book_name')]
+ public string $name,
+ ) {}
+}
```
-If you don't, you can still benefit from this when instantiating `FlatMapper`, i.e.:
+**2. Map your flat results:**
```php
-$flatMapper = (new FlatMapper())
- ->setCacheService($yourCacheService) // PSR-6
- ->setValidateMapping(false)
-;
+use Pixelshaped\FlatMapperBundle\FlatMapper;
+
+$flatMapper = new FlatMapper();
+$authors = $flatMapper->map(AuthorDTO::class, $queryResults);
```
-### Mapping pre-caching
+That's it! You now have properly structured `AuthorDTO` objects with nested `BookDTO` arrays.
+
+## How It Works
+
+### Mapping Attributes
-The mapping for a DTO is created the first time the function is called. Subsequent calls during the same script execution won't recreate the mapping. If a cache service is configured, mapping will be loaded from the cache for next script executions.
+FlatMapper uses PHP attributes to define how flat data maps to your DTOs:
-If you want to cache all your DTOs in advance to avoid doing it on your hotpaths, you can do:
+#### `#[Identifier]` - Required
+
+Every DTO needs exactly one identifier to track unique instances:
+
+```php
+// As a property attribute (when you need the ID in your DTO)
+class AuthorDTO {
+ public function __construct(
+ #[Identifier]
+ #[Scalar('author_id')]
+ public int $id,
+ // ...
+ ) {}
+}
+
+// As a class attribute (when you only need it for internal tracking)
+#[Identifier('product_id')]
+class ProductDTO {
+ public function __construct(
+ #[Scalar('product_sku')]
+ public string $sku,
+ // ...
+ ) {}
+}
+```
+
+#### `#[Scalar("column_name")]` - Optional
+
+Maps a column from your result set to a scalar property. Omit if property names match column names:
```php
-$dtoClassNames = [CustomerDTO::class, ...];
-foreach($dtoClassNames as $className) {
- $flatMapper->createMapping($className);
+class BookDTO {
+ public function __construct(
+ public int $id, // Looks for 'id' column
+ #[Scalar('book_name')]
+ public string $name, // Looks for 'book_name' column
+ ) {}
}
```
-This should be regarded as optional. Mapping information be created in any case when calling:
+#### `#[ReferenceArray(NestedDTO::class)]` - For nested objects
+
+Creates an array of nested DTOs from the denormalized data:
```php
-$flatMapper->map(CustomerDTO::class, $results);
+class AuthorDTO {
+ public function __construct(
+ #[Identifier('author_id')]
+ public int $id,
+
+ #[ReferenceArray(BookDTO::class)]
+ public array $books, // Will contain BookDTO instances
+ ) {}
+}
```
-### Add mapping to your DTOs
+#### `#[ScalarArray("column_name")]` - For arrays of scalars
+
+Collects scalar values (like IDs) into an array:
+
+```php
+class CustomerDTO {
+ public function __construct(
+ #[Identifier('customer_id')]
+ public int $id,
-This bundle comes with several attributes that you can use to add mapping to your DTOs:
+ #[ScalarArray('shopping_list_id')]
+ public array $shoppingListIds, // [1, 2, 3, ...]
+ ) {}
+}
+```
-- `#[Identifier]`: Any DTO has to have exactly one identifier. This identifier is used internally to keep track of the DTO instances and to create them only once. You can:
- - 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 `#[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)).
+#### `#[NameTransformation]` - Class-level attribute
-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:
+Apply consistent naming rules to avoid repeating `#[Scalar]` on every 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.
+```php
+use Pixelshaped\FlatMapperBundle\Mapping\NameTransformation;
-**Note:** The old parameter names `removePrefix` and `camelize` are still supported for backward compatibility but are deprecated in favor of `columnPrefix` and `snakeCaseColumns`.
+// Add a prefix to all column lookups
+#[NameTransformation(columnPrefix: 'author_')]
+class AuthorDTO {
+ public function __construct(
+ #[Identifier]
+ public int $id, // Looks for 'author_id'
+ public string $name, // Looks for 'author_name'
+ ) {}
+}
-
-### Hydrating nested DTOs
+// Convert camelCase to snake_case
+#[NameTransformation(snakeCaseColumns: true)]
+class ProductDTO {
+ public function __construct(
+ #[Identifier]
+ public int $productId, // Looks for 'product_id'
+ public string $productName, // Looks for 'product_name'
+ ) {}
+}
-Given either
+// Combine both
+#[NameTransformation(columnPrefix: 'usr_', snakeCaseColumns: true)]
+class UserDTO {
+ public function __construct(
+ #[Identifier]
+ public int $userId, // Looks for 'usr_user_id'
+ public string $fullName, // Looks for 'usr_full_name'
+ ) {}
+}
+```
-- [AuthorDTO](tests/Examples/Valid/ReferenceArray/AuthorDTO.php) (with property-level attributes)
-- [BookDTO](tests/Examples/Valid/ReferenceArray/BookDTO.php) (with property-level attributes)
+Individual `#[Scalar]` or `#[Identifier]` attributes override class-level transformations.
-or
+## Complete Examples
-- [AuthorDTO](tests/Examples/Valid/ClassAttributes/AuthorDTO.php) (with `NameTransformation`)
-- [BookDTO](tests/Examples/Valid/ClassAttributes/BookDTO.php) (with `NameTransformation`)
+### Nested DTOs Example
+**DTOs:**
+- [AuthorDTO](tests/Examples/Valid/ReferenceArray/AuthorDTO.php)
+- [BookDTO](tests/Examples/Valid/ReferenceArray/BookDTO.php)
-Calling FlatMapper with the following result set:
+**Input (denormalized):**
```php
$results = [
@@ -120,10 +241,10 @@ $results = [
['author_id' => 2, 'author_name' => 'Bob Schmo', 'book_id' => 4, 'book_name' => 'My best recipes', 'book_publisher_name' => 'Cooking and Stuff'],
];
-$flatMapper->map(AuthorDTO::class, $results);
+$authors = $flatMapper->map(AuthorDTO::class, $results);
```
-Will output:
+**Output (nested objects):**
```php
Array
@@ -132,25 +253,25 @@ Array
(
[id] => 1
[name] => Alice Brian
- [leafs] => Array
+ [books] => Array
(
[1] => BookDTO Object
(
[id] => 1
- [name] => "Travelling as a group"
- [publisherName] => "TravelBooks"
+ [name] => Travelling as a group
+ [publisherName] => TravelBooks
)
[2] => BookDTO Object
(
[id] => 2
- [name] => "My journeys"
- [publisherName] => "Lorem Press"
+ [name] => My journeys
+ [publisherName] => Lorem Press
)
[3] => BookDTO Object
(
[id] => 3
- [name] => "Coding on the road"
- [publisherName] => "Ipsum Books"
+ [name] => Coding on the road
+ [publisherName] => Ipsum Books
)
)
)
@@ -158,30 +279,31 @@ Array
(
[id] => 2
[name] => Bob Schmo
- [leafs] => Array
+ [books] => Array
(
[1] => BookDTO Object
(
[id] => 1
- [name] => "Travelling as a group"
- [publisherName] => "TravelBooks"
+ [name] => Travelling as a group
+ [publisherName] => TravelBooks
)
[4] => BookDTO Object
(
[id] => 4
- [name] => "My best recipes"
- [publisherName] => "Cooking and Stuff"
+ [name] => My best recipes
+ [publisherName] => Cooking and Stuff
)
)
)
)
```
-### Hydrating Column Arrays
+### Scalar Arrays Example
+
+**DTO:** [ScalarArrayDTO](tests/Examples/Valid/ScalarArray/ScalarArrayDTO.php)
-Given [ScalarArrayDTO](tests/Examples/Valid/ScalarArray/ScalarArrayDTO.php)
+**Input:**
-Calling FlatMapper with the following result set:
```php
$results = [
['object1_id' => 1, 'object1_name' => 'Root 1', 'object2_id' => 1],
@@ -192,7 +314,7 @@ $results = [
];
```
-Will output:
+**Output:**
```php
Array
@@ -221,104 +343,158 @@ Array
)
```
-### Working with Doctrine Queries
+## Framework Integration
-Given the following DTO class:
+### Symfony
-```php
-getOrCreateQueryBuilder()
+$result = $entityManager->createQueryBuilder()
->select('customer.id AS customer_id, customer.name AS customer_name, shopping_list.id AS shopping_list_id')
- ->leftJoin('customer.shopping_list', 'shopping_list')
- ->getQuery()->getResult()
- ;
+ ->from(Customer::class, 'customer')
+ ->leftJoin('customer.shoppingLists', 'shopping_list')
+ ->getQuery()
+ ->getResult();
-$flatMapper = new \Pixelshaped\FlatMapperBundle\FlatMapper()
-
-$flatMapper->map(CustomerDTO::class, $result);
+$customers = $flatMapper->map(CustomerDTO::class, $result);
```
-Will give you an array of `CustomerDTO`, with the `$shoppingListIds` property populated with an array of corresponding ShoppingList IDs.
-
-### Working with pagination
+### Pagination
-You can still use [Doctrine](https://www.doctrine-project.org/projects/doctrine-orm/en/3.2/tutorials/pagination.html) to paginate your DQL query:
+FlatMapper works with Doctrine's Paginator:
```php
-$qb = $customerRepository->createQueryBuilder('customer');
-$qb
- ->leftJoin('customer.addresses', 'customer_addresses')
- ->select('customer.id AS customer_id, customer.ref AS customer_ref, customer_addresses.id AS address_id')
+$qb = $customerRepository->createQueryBuilder('customer')
+ ->leftJoin('customer.addresses', 'address')
+ ->select('customer.id AS customer_id, customer.ref AS customer_ref, address.id AS address_id')
->setFirstResult(0)
- ->setMaxResults(10)
- ;
+ ->setMaxResults(10);
$paginator = new Paginator($qb->getQuery(), fetchJoinCollection: true);
$paginator->setUseOutputWalkers(false);
-$result = $flatMapper->map(CustomerWithAddressesDTO::class, $paginator);
+$customers = $flatMapper->map(CustomerWithAddressesDTO::class, $paginator);
```
-Will get you an array of 10 `CustomerWithAddressesDTO` (granted you do have 10 in your db).
+### Standalone (No Framework)
+
+```php
+use Pixelshaped\FlatMapperBundle\FlatMapper;
+
+$flatMapper = new FlatMapper();
-### Usage without Symfony
+// Optional: configure for production
+$flatMapper
+ ->setCacheService($psr6CachePool) // Any PSR-6 cache
+ ->setValidateMapping(false); // Skip validation checks
-You can use this package without Symfony. Just instantiate the `FlatMapper` class and use its methods.
+$result = $flatMapper->map(AuthorDTO::class, $queryResults);
+```
+
+## Performance Optimization
-## Alternatives
+### Mapping Cache
-Doctrine [provides a solution](https://www.doctrine-project.org/projects/doctrine-orm/en/2.11/reference/dql-doctrine-query-language.html#new-operator-syntax) to build DTOs directly from a QueryBuilder:
+Mapping metadata is created once per DTO and cached across requests when a cache service is configured. The first call analyzes your DTO attributes; subsequent calls use the cached mapping.
-Given a DTO class such as `CustomerDTO`:
+### Pre-cache Mappings
+
+Avoid creating mappings on hot paths by pre-caching during deployment:
```php
-createMapping($class);
}
```
-Doctrine can execute a query that produces an array `array`:
+This is optional. Mappings are created automatically when calling `map()` if not already cached.
+
+### Disable Validation in Production
+
+Validation checks ensure your DTOs are configured correctly but add a little overhead. Disable in production:
+
+```php
+$flatMapper->setValidateMapping(false);
+```
+
+Or in Symfony:
+```yaml
+pixelshaped_flat_mapper:
+ validate_mapping: '%kernel.debug%' # true in dev, false in prod
+```
+
+## Why Not Just Use...?
+
+### Doctrine Entities
+
+FlatMapper is significantly faster for read operations ([see benchmarks](https://github.com/Pixelshaped/flat-mapper-benchmark)):
+- ~2x faster execution time
+- 40-60% less memory usage
+- No lazy-loading surprises
+
+Using full Doctrine entities for reads also:
+- Risks coupling your templates/views to your domain model
+- Loads entity metadata and change tracking overhead
+- Can trigger lazy-loading and N+1 queries (even with proper JOINs, proxies add overhead)
+
+FlatMapper gives you lightweight, read-only DTOs optimized for queries.
+
+### Doctrine's `NEW` Operator
+
+Doctrine can create DTOs directly in DQL:
```php
-createQuery('SELECT NEW CustomerDTO(c.name, e.email, a.city) FROM Customer c JOIN c.email e JOIN c.address a');
-$users = $query->getResult(); // array
+$customers = $query->getResult(); // array
```
-Unfortunately, if you need to retrieve DTOs with non-scalar properties, such as:
+**Limitation:** Only supports scalar properties. You can't have:
+- Arrays of nested DTOs (`#[ReferenceArray]`)
+- Arrays of IDs or other scalar arrays (`#[ScalarArray]`)
+- Complex object graphs
+
+FlatMapper solves this by handling denormalized data at any nesting level.
+
+### Other Object Mappers
+
+Most object mappers transform **nested arrays** (like JSON) to objects:
+
+- [mark-gerarts/automapper-plus](https://github.com/mark-gerarts/automapper-plus) - Maps entities to DTOs (normalized data)
+- [jolicode/automapper](https://github.com/jolicode/automapper) - Maps normalized objects/arrays
+- [sunrise-php/hydrator](https://github.com/sunrise-php/hydrator) - Maps normalized arrays to objects
+
+**These don't handle denormalized data** where:
+- Parent information repeats across multiple rows
+- Relationships need to be reconstructed from flat results
+- One row doesn't equal one object
+
+### `PARTIAL` Objects + Manual Mapping
+
+You could use Doctrine's `PARTIAL` objects then map to DTOs, but:
+- No indication whether an object is fully loaded
+- Two-step process (entity hydration + DTO mapping)
+- Higher complexity than direct flat-to-DTO mapping
-- an array of IDs
-- an array of nested DTOs
+## Contributing
-then, the solution provided by Doctrine doesn't work. The creation of this bundle arose from that situation.
+Found a bug or have a suggestion? Please [open an issue](https://github.com/pixelshaped/flat-mapper-bundle/issues) or submit a pull request.
-When I started coding this, I looked for alternatives but found only partial ones:
+Know of an alternative that solves similar problems? Let us know—we'd love to reference it here!
-- [mark-gerarts/automapper-plus](https://github.com/mark-gerarts/automapper-plus) is great at mapping objects to other objects (namely, entities to DTOs and vice versa), but doesn't solve the problem of mapping denormalized data (i.e. an array with the information for several objects on each row and a lot of redundancy between rows) to objects.
-- [jolicode/automapper](https://github.com/jolicode/automapper) is a great alternative to the previous bundle with the same limitations.
-- [sunrise-php/hydrator](https://github.com/sunrise-php/hydrator) can map arrays to objects, but not denormalized arrays
-- Several other bundles can map JSON info to objects.
-- [doctrine/orm](https://github.com/doctrine/orm) solves this problem internally using [ResultSetMapping](https://www.doctrine-project.org/projects/doctrine-orm/en/2.14/reference/native-sql.html#the-resultsetmapping). It can join on Entities but can't for DTOs because there's no way do declare a mapping for a DTO. That's actually why Doctrine only handles DTOs with scalar properties.
-- You can technically build `PARTIAL` objects with Doctrine, but I consider this to be a bad practice as the next developer has no idea if the object at hand is a complete one or not. You could then map it to a DTO and discard it to avoid this situation, but the algorithmic complexity will likely be higher than the mapping we do with our bundle (O(n)).
+## License
-Do not hesitate to suggest alternatives or to contribute.
+This bundle is released under the MIT License. See the [LICENSE](LICENSE) file for details.
diff --git a/src/FlatMapper.php b/src/FlatMapper.php
index 79066bf..10326b2 100644
--- a/src/FlatMapper.php
+++ b/src/FlatMapper.php
@@ -3,6 +3,7 @@
namespace Pixelshaped\FlatMapperBundle;
+use Error;
use Pixelshaped\FlatMapperBundle\Exception\MappingCreationException;
use Pixelshaped\FlatMapperBundle\Exception\MappingException;
use Pixelshaped\FlatMapperBundle\Mapping\Identifier;
@@ -10,13 +11,16 @@
use Pixelshaped\FlatMapperBundle\Mapping\ReferenceArray;
use Pixelshaped\FlatMapperBundle\Mapping\Scalar;
use Pixelshaped\FlatMapperBundle\Mapping\ScalarArray;
-use Error;
use ReflectionClass;
use ReflectionProperty;
use Symfony\Contracts\Cache\CacheInterface;
final class FlatMapper
{
+ // Pre-compiled regex patterns for better performance
+ private const SNAKE_CASE_PATTERN_1 = '/([A-Z]+)([A-Z][a-z])/';
+ private const SNAKE_CASE_PATTERN_2 = '/([a-z\d])([A-Z])/';
+ private const SNAKE_CASE_REPLACEMENT = '\1_\2';
/**
* @var array>
@@ -48,7 +52,7 @@ public function createMapping(string $dtoClassName): void
if(!isset($this->objectsMapping[$dtoClassName])) {
if($this->cacheService !== null) {
- $cacheKey = preg_replace("/([^a-zA-Z0-9]+)/","_", $dtoClassName);;
+ $cacheKey = strtr($dtoClassName, ['\\' => '_', '-' => '_', ' ' => '_']);
$mappingInfo = $this->cacheService->get('pixelshaped_flat_mapper_'.$cacheKey, function () use ($dtoClassName): array {
return $this->createMappingRecursive($dtoClassName);
});
@@ -151,9 +155,13 @@ private function createMappingRecursive(string $dtoClassName, ?array& $objectIde
if($identifiersCount !== 1) {
throw new MappingCreationException($dtoClassName.' does not contain exactly one #[Identifier] attribute.');
}
-
- if (count($objectIdentifiers) !== count(array_unique($objectIdentifiers))) {
- throw new MappingCreationException('Several data identifiers are identical: ' . print_r($objectIdentifiers, true));
+
+ $uniqueCheck = [];
+ foreach ($objectIdentifiers as $key => $value) {
+ if (isset($uniqueCheck[$value])) {
+ throw new MappingCreationException('Several data identifiers are identical: ' . print_r($objectIdentifiers, true));
+ }
+ $uniqueCheck[$value] = true;
}
}
@@ -167,8 +175,8 @@ private function transformPropertyName(string $propertyName, NameTransformation
{
if ($transformation->snakeCaseColumns) {
$propertyName = strtolower(preg_replace(
- ['/([A-Z]+)([A-Z][a-z])/', '/([a-z\d])([A-Z])/'],
- '\1_\2',
+ [self::SNAKE_CASE_PATTERN_1, self::SNAKE_CASE_PATTERN_2],
+ self::SNAKE_CASE_REPLACEMENT,
$propertyName
) ?? $propertyName);
}