Skip to content

Commit 8c6141c

Browse files
committed
DefaultEntityMapping: added except map for columns with no camelCase form
Digits have no letter case, so an underscore directly before a digit (address_2) is not representable in camelCase and mapping validation rejects such columns. The except option (DI key mapping.except) declares explicit property-to-column pairs that take precedence over the conversion in both directions; the validation message now hints at it.
1 parent b59bd1d commit 8c6141c

6 files changed

Lines changed: 143 additions & 16 deletions

File tree

‎docs/internals/explorer.md‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,10 +121,17 @@ Boundaries and traps:
121121
why the implementation is context-free.
122122
- **Validation runs only at structure rebuild.** Enabling the mapping requires the
123123
whole schema to be invertible, including tables and views the application never
124-
touches. A warm structure cache skips
124+
touches (the except map is the escape hatch). A warm structure cache skips
125125
validation entirely, so a change of the mapping configuration requires a cache
126126
flush, same as a schema change; a structure cache built by a process without the
127127
mapping (CLI, another app sharing the DSN and cache dir) is trusted unvalidated.
128+
- **Digits have no letter case**, so the invertible subset is stricter than plain
129+
snake_case: digit runs must attach to the preceding word (`address2`,
130+
`sha256_hash`, `oauth2_token` are fine); an underscore directly before a digit
131+
(`address_2`) is not representable in camelCase and is rejected by validation.
132+
The `except` map of `DefaultEntityMapping` (DI key `mapping: except:`) declares
133+
explicit property→column pairs that take precedence over the conversion in both
134+
directions — the escape hatch for such legacy columns.
128135

129136
## `accessColumn`: the single shared seam
130137

‎src/Bridges/DatabaseDI/DatabaseExtension.php‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ public function getConfigSchema(): Nette\Schema\Schema
4444
Expect::arrayOf('string', 'string'),
4545
)->default([]),
4646
'camelCase' => Expect::bool(false),
47+
'except' => Expect::arrayOf('string', 'string'),
4748
]),
4849
]),
4950
)->before(fn($val) => is_array($val) && $val && !array_key_exists('dsn', $val)
@@ -126,10 +127,10 @@ private function setupDatabase(\stdClass $config, string $name): void
126127
->setAutowired($config->autowired)
127128
: null;
128129

129-
$entityMapping = $config->mapping?->tables || $config->mapping?->camelCase
130+
$entityMapping = $config->mapping?->tables || $config->mapping?->camelCase || $config->mapping?->except
130131
? new Nette\DI\Definitions\Statement(
131132
Nette\Database\DefaultEntityMapping::class,
132-
[$config->mapping->tables, $config->mapping->camelCase],
133+
[$config->mapping->tables, $config->mapping->camelCase, $config->mapping->except],
133134
)
134135
: null;
135136

‎src/Database/DefaultEntityMapping.php‎

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ final class DefaultEntityMapping implements EntityMapping
2121
private array $columnCache = [];
2222

2323

24+
/**
25+
* @param array<string, string> $except explicit property-to-column pairs (e.g. ['address2' => 'address_2'])
26+
* that take precedence over the camelCase conversion in both directions; the escape hatch
27+
* for columns whose name has no camelCase form
28+
*/
2429
public function __construct(
2530
/**
2631
* @var array<string, class-string<Table\ActiveRow>|string> table-to-class map; keys
@@ -32,7 +37,12 @@ public function __construct(
3237
private readonly array $tables = [],
3338
/** whether to convert snake_case column names to camelCase properties */
3439
private readonly bool $camelCase = false,
40+
array $except = [],
3541
) {
42+
foreach ($except as $property => $column) {
43+
$this->propertyCache[$column] = $property;
44+
$this->columnCache[$property] = $column;
45+
}
3646
}
3747

3848

@@ -72,27 +82,28 @@ private function expandClass(string $class, string $capture): string
7282

7383
/**
7484
* With camelCase enabled, expects a snake_case column name (e.g. 'first_name')
75-
* and returns its camelCase property form (e.g. 'firstName').
85+
* and returns its camelCase property form (e.g. 'firstName'). Pairs from the
86+
* except map take precedence.
7687
*/
7788
public function getPropertyName(string $name): string
7889
{
79-
return $this->camelCase
80-
? $this->propertyCache[$name] ??= lcfirst(self::toPascalCase($name))
81-
: $name;
90+
return $this->propertyCache[$name] ??= $this->camelCase ? lcfirst(self::toPascalCase($name)) : $name;
8291
}
8392

8493

8594
/**
8695
* With camelCase enabled, expects a camelCase property name (e.g. 'firstName')
8796
* and returns its snake_case column form (e.g. 'first_name'). PascalCase
8897
* input would produce a leading underscore (e.g. 'FirstName' → '_first_name'),
89-
* so the first letter is expected to be lowercase.
98+
* so the first letter is expected to be lowercase. Digits have no letter case,
99+
* so an underscore directly before a digit ('address_2') has no camelCase form;
100+
* digit runs attach to the preceding word ('sha256_hash' → 'sha256Hash') and
101+
* columns like 'address_2' must go through the except map. Pairs from the
102+
* except map take precedence.
90103
*/
91104
public function getColumnName(string $name): string
92105
{
93-
return $this->camelCase
94-
? $this->columnCache[$name] ??= strtolower((string) preg_replace('#[A-Z]#', '_$0', $name))
95-
: $name;
106+
return $this->columnCache[$name] ??= $this->camelCase ? strtolower((string) preg_replace('#[A-Z]#', '_$0', $name)) : $name;
96107
}
97108

98109

‎src/Database/Explorer.php‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,10 @@ public function validateEntityMapping(): void
158158
$property = $this->entityMapping->getPropertyName($column['name']);
159159
$back = $this->entityMapping->getColumnName($property);
160160
if ($back !== $column['name']) {
161-
throw new Nette\InvalidStateException("Column '$table.{$column['name']}' cannot be addressed through entity mapping: its property name '$property' translates back to column '$back'. Rename the column or adjust the mapping.");
161+
$hint = preg_match('#_\d#', $column['name'])
162+
? "An underscore directly before a digit has no camelCase form; map the column explicitly via the mapping's except option, or rename it."
163+
: 'Rename the column or adjust the mapping.';
164+
throw new Nette\InvalidStateException("Column '$table.{$column['name']}' cannot be addressed through entity mapping: its property name '$property' translates back to column '$back'. $hint");
162165
}
163166
}
164167
}

‎tests/Database.DI/DatabaseExtension.entityMapping.phpt‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,24 @@ test('tables string shortcut', function () {
6767
});
6868

6969

70+
test('mapping with except only', function () {
71+
$container = createContainer('
72+
database:
73+
dsn: "sqlite::memory:"
74+
mapping:
75+
except:
76+
address2: address_2
77+
debugger: no
78+
79+
services:
80+
cache: Nette\Caching\Storages\DevNullStorage
81+
', 'Container2c');
82+
83+
$explorer = $container->getService('database.default.explorer');
84+
Assert::type(DefaultEntityMapping::class, getEntityMapping($explorer));
85+
});
86+
87+
7088
test('no mapping by default', function () {
7189
$container = createContainer('
7290
database:
@@ -154,6 +172,26 @@ test('DefaultEntityMapping: camelCase roundtrip', function () {
154172
});
155173

156174

175+
test('DefaultEntityMapping: except pairs take precedence in both directions', function () {
176+
$mapping = new DefaultEntityMapping(camelCase: true, except: ['address2' => 'address_2']);
177+
178+
Assert::same('address2', $mapping->getPropertyName('address_2'));
179+
Assert::same('address_2', $mapping->getColumnName('address2'));
180+
Assert::same('firstName', $mapping->getPropertyName('first_name'));
181+
Assert::same('first_name', $mapping->getColumnName('firstName'));
182+
});
183+
184+
185+
test('DefaultEntityMapping: except works without camelCase', function () {
186+
$mapping = new DefaultEntityMapping(except: ['address2' => 'address_2']);
187+
188+
Assert::same('address2', $mapping->getPropertyName('address_2'));
189+
Assert::same('address_2', $mapping->getColumnName('address2'));
190+
Assert::same('other_col', $mapping->getPropertyName('other_col'));
191+
Assert::same('otherCol', $mapping->getColumnName('otherCol'));
192+
});
193+
194+
157195
test('DefaultEntityMapping: schema prefix is stripped in class name', function () {
158196
$mapping = new DefaultEntityMapping(['*' => 'App\Model\*Row']);
159197

‎tests/Database.DI/EntityMapping.validation.phpt‎

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
/**
44
* Test: entity-mapping name translation is validated against the actual schema when
55
* the structure cache is being (re)built: a column unreachable through the mapping
6-
* (camelCase column under camelCase mapping, colliding column pair) fails loudly
7-
* instead of silently, and the failure aborts caching so it repeats until fixed.
6+
* (camelCase column under camelCase mapping, colliding column pair, underscore
7+
* directly before a digit) fails loudly instead of silently, and the failure aborts
8+
* caching so it repeats until fixed; the except map is the escape hatch.
89
*/
910

1011
use Nette\Caching\Storages\MemoryStorage;
@@ -15,7 +16,7 @@ use Tester\Assert;
1516
require __DIR__ . '/../bootstrap.php';
1617

1718

18-
function createExplorer(array $schema, bool $camelCase): Explorer
19+
function createExplorer(array $schema, bool $camelCase, array $except = []): Explorer
1920
{
2021
$connection = new Connection('sqlite::memory:');
2122
foreach ($schema as $sql) {
@@ -28,7 +29,7 @@ function createExplorer(array $schema, bool $camelCase): Explorer
2829
$structure,
2930
new DiscoveredConventions($structure),
3031
$storage,
31-
new DefaultEntityMapping([], camelCase: $camelCase),
32+
new DefaultEntityMapping([], camelCase: $camelCase, except: $except),
3233
);
3334
}
3435

@@ -78,6 +79,72 @@ test('a colliding column pair fails loudly', function () {
7879
});
7980

8081

82+
test('digit runs attached to the preceding word are invertible and pass', function () {
83+
$explorer = createExplorer(
84+
['CREATE TABLE digits (id INTEGER PRIMARY KEY AUTOINCREMENT, address2 INT, sha256_hash TEXT, oauth2_token TEXT, utf8mb4_name TEXT, col_v2 INT)'],
85+
camelCase: true,
86+
);
87+
$explorer->table('digits')->insert(['address2' => 1, 'sha256Hash' => 'x', 'oauth2Token' => 'y', 'utf8mb4Name' => 'z', 'colV2' => 2]);
88+
$row = $explorer->table('digits')->fetch();
89+
Assert::same(1, $row->address2);
90+
Assert::same('x', $row->sha256Hash);
91+
Assert::same(2, $row->colV2);
92+
});
93+
94+
95+
test('an underscore directly before a digit fails with a digit hint', function () {
96+
$explorer = createExplorer(
97+
['CREATE TABLE legacy (id INTEGER PRIMARY KEY AUTOINCREMENT, address_2 INT)'],
98+
camelCase: true,
99+
);
100+
Assert::exception(
101+
fn() => $explorer->table('legacy'),
102+
Nette\InvalidStateException::class,
103+
"Column 'legacy.address_2' cannot be addressed through entity mapping: its property name 'address2' translates back to column 'address2'. An underscore directly before a digit has no camelCase form; map the column explicitly via the mapping's except option, or rename it.",
104+
);
105+
});
106+
107+
108+
test('except map makes such a column addressable in both directions', function () {
109+
$explorer = createExplorer(
110+
['CREATE TABLE legacy (id INTEGER PRIMARY KEY AUTOINCREMENT, address_2 INT)'],
111+
camelCase: true,
112+
except: ['address2' => 'address_2'],
113+
);
114+
$explorer->table('legacy')->insert(['address2' => 5]);
115+
$row = $explorer->table('legacy')->where('address2', 5)->fetch();
116+
Assert::same(5, $row->address2);
117+
Assert::same(['id' => 1, 'address2' => 5], $row->toArray());
118+
$row->update(['address2' => 6]);
119+
Assert::same(6, $explorer->table('legacy')->fetch()->address2);
120+
});
121+
122+
123+
test('except pair whose column collides with a real column fails loudly', function () {
124+
$explorer = createExplorer(
125+
['CREATE TABLE digits (id INTEGER PRIMARY KEY AUTOINCREMENT, address2 INT)'],
126+
camelCase: true,
127+
except: ['address2' => 'address_2'],
128+
);
129+
Assert::exception(
130+
fn() => $explorer->table('digits'),
131+
Nette\InvalidStateException::class,
132+
"Column 'digits.address2' cannot be addressed through entity mapping: its property name 'address2' translates back to column 'address_2'. Rename the column or adjust the mapping.",
133+
);
134+
});
135+
136+
137+
test('except map works without camelCase conversion', function () {
138+
$explorer = createExplorer(
139+
['CREATE TABLE legacy (id INTEGER PRIMARY KEY AUTOINCREMENT, address_2 INT)'],
140+
camelCase: false,
141+
except: ['address2' => 'address_2'],
142+
);
143+
$explorer->table('legacy')->insert(['address2' => 3]);
144+
Assert::same(3, $explorer->table('legacy')->fetch()->address2);
145+
});
146+
147+
81148
test('identity mapping (camelCase off) accepts any schema', function () {
82149
$explorer = createExplorer([
83150
'CREATE TABLE broken (id INTEGER PRIMARY KEY AUTOINCREMENT, realCamel INT)',

0 commit comments

Comments
 (0)