Skip to content

Commit eac826d

Browse files
committed
EntityMapping: added column-to-property name translation
1 parent 0fc298b commit eac826d

9 files changed

Lines changed: 404 additions & 19 deletions

File tree

src/Bridges/DatabaseDI/DatabaseExtension.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
* autowired: ?bool,
2929
* mapping: ?object{
3030
* tables: array<string, string>,
31+
* camelCase: bool,
3132
* },
3233
* }> $config
3334
*/
@@ -57,6 +58,7 @@ public function getConfigSchema(): Nette\Schema\Schema
5758
Expect::string()->transform(fn(string $v) => ['*' => $v]),
5859
Expect::arrayOf('string', 'string'),
5960
)->default([]),
61+
'camelCase' => Expect::bool(false),
6062
]),
6163
]),
6264
)->before(fn($val) => is_array(reset($val)) || reset($val) === null
@@ -142,8 +144,8 @@ private function setupDatabase(\stdClass $config, string $name): void
142144
$conventions = Nette\DI\Helpers::filterArguments([$config->conventions])[0];
143145
}
144146

145-
$entityMapping = $config->mapping?->tables
146-
? new Nette\DI\Definitions\Statement(Nette\Database\DefaultEntityMapping::class, [$config->mapping->tables])
147+
$entityMapping = $config->mapping?->tables || $config->mapping?->camelCase
148+
? new Nette\DI\Definitions\Statement(Nette\Database\DefaultEntityMapping::class, [$config->mapping->tables, $config->mapping->camelCase])
147149
: null;
148150

149151
$builder->addDefinition($this->prefix("$name.explorer"))

src/Database/DefaultEntityMapping.php

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,24 @@
1414
*/
1515
final class DefaultEntityMapping implements EntityMapping
1616
{
17+
/** @var array<string, string> */
18+
private array $propertyCache = [];
19+
20+
/** @var array<string, string> */
21+
private array $columnCache = [];
22+
23+
1724
/**
1825
* @param array<string, class-string<Table\ActiveRow>|string> $tables table-to-class map; keys
1926
* may contain a single '*' wildcard (e.g. 'forum_*'), and a bare '*' acts as a catch-all
2027
* fallback. Class names may contain '*' which is replaced with PascalCase of the captured
2128
* portion (or the full table name for exact keys). Exact keys take precedence; wildcard
2229
* entries are tried in declaration order.
30+
* @param bool $camelCase whether to convert snake_case column names to camelCase properties
2331
*/
2432
public function __construct(
2533
private readonly array $tables = [],
34+
private readonly bool $camelCase = false,
2635
) {
2736
}
2837

@@ -61,6 +70,32 @@ private function expandClass(string $class, string $capture): string
6170
}
6271

6372

73+
/**
74+
* With camelCase enabled, expects a snake_case column name (e.g. 'first_name')
75+
* and returns its camelCase property form (e.g. 'firstName').
76+
*/
77+
public function getPropertyName(string $name): string
78+
{
79+
return $this->camelCase
80+
? $this->propertyCache[$name] ??= lcfirst(self::toPascalCase($name))
81+
: $name;
82+
}
83+
84+
85+
/**
86+
* With camelCase enabled, expects a camelCase property name (e.g. 'firstName')
87+
* and returns its snake_case column form (e.g. 'first_name'). PascalCase
88+
* input would produce a leading underscore (e.g. 'FirstName' → '_first_name'),
89+
* so the first letter is expected to be lowercase.
90+
*/
91+
public function getColumnName(string $name): string
92+
{
93+
return $this->camelCase
94+
? $this->columnCache[$name] ??= strtolower((string) preg_replace('#[A-Z]#', '_$0', $name))
95+
: $name;
96+
}
97+
98+
6499
private static function toPascalCase(string $name): string
65100
{
66101
$name = preg_replace('#^.*\.#', '', $name); // strip schema prefix

src/Database/EntityMapping.php

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010

1111
/**
12-
* Resolves PHP class name for each database table.
12+
* Translates identifier names between PHP conventions and database conventions.
1313
*/
1414
interface EntityMapping
1515
{
@@ -18,4 +18,17 @@ interface EntityMapping
1818
* @return ?class-string<Table\ActiveRow>
1919
*/
2020
function getClassName(string $table): ?string;
21+
22+
/**
23+
* Translates database column name to PHP property name.
24+
*/
25+
function getPropertyName(string $name): string;
26+
27+
/**
28+
* Translates PHP property name to database column name. In dotted paths
29+
* (e.g. 'book.title' in WHERE/ORDER fragments) it is invoked only on the
30+
* last segment; preceding segments are treated as table/alias names and
31+
* left untouched.
32+
*/
33+
function getColumnName(string $name): string;
2134
}

src/Database/Helpers.php

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
use Nette;
1111
use Nette\Bridges\DatabaseTracy\ConnectionPanel;
1212
use Tracy;
13-
use function array_filter, array_keys, array_unique, count, fclose, fgets, fopen, fstat, get_resource_type, htmlspecialchars, implode, is_bool, is_float, is_resource, is_string, preg_last_error, preg_match, preg_replace, preg_replace_callback, reset, rtrim, set_time_limit, str_ends_with, str_starts_with, stream_get_meta_data, strlen, strncasecmp, substr, trim, wordwrap;
13+
use function array_filter, array_keys, array_unique, count, fclose, fgets, fopen, fstat, get_resource_type, htmlspecialchars, implode, is_bool, is_float, is_int, is_resource, is_string, preg_last_error, preg_match, preg_replace, preg_replace_callback, reset, rtrim, set_time_limit, str_ends_with, str_starts_with, stream_get_meta_data, strlen, strncasecmp, substr, trim, wordwrap;
1414

1515

1616
/**
@@ -405,6 +405,29 @@ public static function findDuplicates(\PDOStatement $statement): string
405405
}
406406

407407

408+
/**
409+
* Translates array keys from PHP property names to database column names via EntityMapping.
410+
* Preserves integer keys and compound assignment operator suffixes (e.g. `firstName+=`).
411+
* @param array<int|string, mixed> $data
412+
* @return array<int|string, mixed>
413+
* @internal
414+
*/
415+
public static function translateColumns(array $data, EntityMapping $mapping): array
416+
{
417+
$result = [];
418+
foreach ($data as $key => $value) {
419+
if (is_int($key)) {
420+
$result[$key] = $value;
421+
} elseif (preg_match('#^(.*?)([+\-]?=)$#D', $key, $m)) {
422+
$result[$mapping->getColumnName($m[1]) . $m[2]] = $value;
423+
} else {
424+
$result[$mapping->getColumnName($key)] = $value;
425+
}
426+
}
427+
return $result;
428+
}
429+
430+
408431
/**
409432
* Parses a SQL column type string into its components.
410433
* @return array{type: ?string, size: ?int, scale: ?int, parameters: ?string}

src/Database/Table/ActiveRow.php

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
namespace Nette\Database\Table;
99

1010
use Nette;
11-
use function array_intersect_key, array_key_exists, array_keys, implode, is_array, iterator_to_array;
11+
use function array_intersect_key, array_key_exists, array_keys, array_map, implode, is_array, iterator_to_array;
1212

1313

1414
/**
@@ -19,6 +19,7 @@
1919
class ActiveRow implements \IteratorAggregate, IRow
2020
{
2121
private bool $dataRefreshed = false;
22+
private readonly ?Nette\Database\EntityMapping $entityMapping;
2223

2324

2425
public function __construct(
@@ -27,6 +28,7 @@ public function __construct(
2728
/** @var Selection<ActiveRow> */
2829
private Selection $table,
2930
) {
31+
$this->entityMapping = $table->getExplorer()->getEntityMapping();
3032
}
3133

3234

@@ -66,12 +68,23 @@ public function __toString(): string
6668
public function toArray(): array
6769
{
6870
$this->accessColumn(null);
71+
$entityMapping = $this->entityMapping;
72+
if ($entityMapping) {
73+
$translated = [];
74+
foreach ($this->data as $key => $value) {
75+
$translated[$entityMapping->getPropertyName($key)] = $value;
76+
}
77+
return $translated;
78+
}
6979
return $this->data;
7080
}
7181

7282

7383
/**
7484
* Returns primary key value, or an array of values for composite primary keys.
85+
* Composite key arrays are keyed by database column names (unlike toArray(),
86+
* which uses property names) so the result can be passed directly to
87+
* Selection::wherePrimary().
7588
*/
7689
public function getPrimary(bool $throw = true): mixed
7790
{
@@ -163,7 +176,10 @@ public function update(iterable $data): bool
163176
->wherePrimary($primary);
164177

165178
if ($selection->update($data)) {
166-
if ($tmp = array_intersect_key($data, $primary)) {
179+
$columnData = $this->entityMapping
180+
? Nette\Database\Helpers::translateColumns($data, $this->entityMapping)
181+
: $data;
182+
if ($tmp = array_intersect_key($columnData, $primary)) {
167183
$selection = $this->table->createSelectionInstance()
168184
->wherePrimary($tmp + $primary);
169185
}
@@ -205,8 +221,7 @@ public function delete(): int
205221
/** @return \ArrayIterator<string, mixed> */
206222
public function getIterator(): \Iterator
207223
{
208-
$this->accessColumn(null);
209-
return new \ArrayIterator($this->data);
224+
return new \ArrayIterator($this->toArray());
210225
}
211226

212227

@@ -250,8 +265,10 @@ public function __set(string $column, mixed $value): void
250265
*/
251266
public function &__get(string $key): mixed
252267
{
253-
if ($this->accessColumn($key)) {
254-
return $this->data[$key];
268+
$column = $this->entityMapping?->getColumnName($key) ?? $key;
269+
270+
if ($this->accessColumn($column)) {
271+
return $this->data[$column];
255272
}
256273

257274
$referenced = $this->table->getReferencedTable($this, $key);
@@ -260,16 +277,21 @@ public function &__get(string $key): mixed
260277
return $referenced;
261278
}
262279

263-
$this->removeAccessColumn($key);
264-
$hint = Nette\Utils\Helpers::getSuggestion(array_keys($this->data), $key);
280+
$this->removeAccessColumn($column);
281+
$available = $this->entityMapping
282+
? array_map(fn(string $col) => $this->entityMapping->getPropertyName($col), array_keys($this->data))
283+
: array_keys($this->data);
284+
$hint = Nette\Utils\Helpers::getSuggestion($available, $key);
265285
throw new Nette\MemberAccessException("Cannot read an undeclared column '$key'" . ($hint ? ", did you mean '$hint'?" : '.'));
266286
}
267287

268288

269289
public function __isset(string $key): bool
270290
{
271-
if ($this->accessColumn($key)) {
272-
return isset($this->data[$key]);
291+
$column = $this->entityMapping?->getColumnName($key) ?? $key;
292+
293+
if ($this->accessColumn($column)) {
294+
return isset($this->data[$column]);
273295
}
274296

275297
$referenced = $this->table->getReferencedTable($this, $key);
@@ -278,7 +300,7 @@ public function __isset(string $key): bool
278300
return (bool) $referenced;
279301
}
280302

281-
$this->removeAccessColumn($key);
303+
$this->removeAccessColumn($column);
282304
return false;
283305
}
284306

src/Database/Table/Selection.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -829,6 +829,12 @@ public function insert(iterable $data): ActiveRow|array|int
829829
$data = iterator_to_array($data);
830830
}
831831

832+
if ($mapping = $this->explorer->getEntityMapping()) {
833+
$data = isset($data[0]) && is_array($data[0])
834+
? array_map(fn(array $row) => Nette\Database\Helpers::translateColumns($row, $mapping), $data)
835+
: Nette\Database\Helpers::translateColumns($data, $mapping);
836+
}
837+
832838
$return = $this->explorer->query($this->sqlBuilder->buildInsertQuery() . ' ?values', $data);
833839
}
834840

@@ -910,6 +916,10 @@ public function update(iterable $data): int
910916
return 0;
911917
}
912918

919+
if ($mapping = $this->explorer->getEntityMapping()) {
920+
$data = Nette\Database\Helpers::translateColumns($data, $mapping);
921+
}
922+
913923
return $this->explorer
914924
->query($this->sqlBuilder->buildUpdateQuery(), ...array_merge([$data], $this->sqlBuilder->getParameters()))
915925
->getRowCount()

src/Database/Table/SqlBuilder.php

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use Nette;
1111
use Nette\Database\Conventions;
1212
use Nette\Database\Driver;
13+
use Nette\Database\EntityMapping;
1314
use Nette\Database\Explorer;
1415
use Nette\Database\IStructure;
1516
use Nette\Database\SqlLiteral;
@@ -62,6 +63,7 @@ class SqlBuilder
6263
protected string $currentAlias = '';
6364
private readonly Driver $driver;
6465
private readonly IStructure $structure;
66+
private readonly ?EntityMapping $entityMapping;
6567

6668
/** @var array<string, int> table fullName => exists */
6769
private array $cacheTableList = [];
@@ -76,6 +78,7 @@ public function __construct(string $tableName, Explorer $explorer)
7678
$this->driver = $explorer->getConnection()->getDriver();
7779
$this->conventions = $explorer->getConventions();
7880
$this->structure = $explorer->getStructure();
81+
$this->entityMapping = $explorer->getEntityMapping();
7982
$tableNameParts = explode('.', $tableName);
8083
$this->delimitedTable = implode('.', array_map($this->driver->delimite(...), $tableNameParts));
8184
$this->checkUniqueTableName(end($tableNameParts), $tableName);
@@ -898,11 +901,31 @@ protected function buildQueryEnd(): string
898901
*/
899902
protected function tryDelimite(string $s): string
900903
{
904+
if (!$this->entityMapping) {
905+
return preg_replace_callback(
906+
'#(?<=[^\w`"\[?:]|^)[a-z_][a-z0-9_]*(?=[^\w`"(\]]|$)#Di',
907+
fn(array $m): string => strtoupper($m[0]) === $m[0]
908+
? $m[0]
909+
: $this->driver->delimite($m[0]),
910+
$s,
911+
);
912+
}
913+
901914
return preg_replace_callback(
902-
'#(?<=[^\w`"\[?:]|^)[a-z_][a-z0-9_]*(?=[^\w`"(\]]|$)#Di',
903-
fn(array $m): string => strtoupper($m[0]) === $m[0]
904-
? $m[0]
905-
: $this->driver->delimite($m[0]),
915+
'#(?<=[^\w`"\[?:]|^)[a-z_][a-z0-9_]*(?:\.[a-z_][a-z0-9_]*)*(?=[^\w`"(\]]|$)#Di',
916+
function (array $m): string {
917+
$parts = explode('.', $m[0]);
918+
$last = count($parts) - 1;
919+
foreach ($parts as $i => &$part) {
920+
if (strtoupper($part) !== $part) {
921+
if ($i === $last) {
922+
$part = $this->entityMapping->getColumnName($part);
923+
}
924+
$part = $this->driver->delimite($part);
925+
}
926+
}
927+
return implode('.', $parts);
928+
},
906929
$s,
907930
);
908931
}

tests/Database.DI/DatabaseExtension.entityMapping.phpt

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,42 @@ test('DefaultEntityMapping: empty map returns null', function () {
119119
});
120120

121121

122+
test('DefaultEntityMapping: camelCase off = identity', function () {
123+
$mapping = new DefaultEntityMapping;
124+
125+
Assert::same('first_name', $mapping->getPropertyName('first_name'));
126+
Assert::same('firstName', $mapping->getColumnName('firstName'));
127+
});
128+
129+
130+
test('DefaultEntityMapping: camelCase on', function () {
131+
$mapping = new DefaultEntityMapping(camelCase: true);
132+
133+
Assert::same('firstName', $mapping->getPropertyName('first_name'));
134+
Assert::same('authorId', $mapping->getPropertyName('author_id'));
135+
Assert::same('id', $mapping->getPropertyName('id'));
136+
Assert::same('name', $mapping->getPropertyName('name'));
137+
Assert::same('createdAt', $mapping->getPropertyName('created_at'));
138+
Assert::same('address2', $mapping->getPropertyName('address2'));
139+
140+
Assert::same('first_name', $mapping->getColumnName('firstName'));
141+
Assert::same('author_id', $mapping->getColumnName('authorId'));
142+
Assert::same('id', $mapping->getColumnName('id'));
143+
Assert::same('name', $mapping->getColumnName('name'));
144+
Assert::same('created_at', $mapping->getColumnName('createdAt'));
145+
Assert::same('address2', $mapping->getColumnName('address2'));
146+
});
147+
148+
149+
test('DefaultEntityMapping: camelCase roundtrip', function () {
150+
$mapping = new DefaultEntityMapping(camelCase: true);
151+
152+
foreach (['id', 'name', 'first_name', 'author_id', 'created_at', 'address2'] as $column) {
153+
Assert::same($column, $mapping->getColumnName($mapping->getPropertyName($column)));
154+
}
155+
});
156+
157+
122158
test('DefaultEntityMapping: schema prefix is stripped in class name', function () {
123159
$mapping = new DefaultEntityMapping(['*' => 'App\Model\*Row']);
124160

0 commit comments

Comments
 (0)