Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
added row mapping support via setRowMapping() callback and mapping
…config option
  • Loading branch information
dg committed Mar 9, 2026
commit bd9887322ea4fb72faa1384e374a249eb4353426
37 changes: 36 additions & 1 deletion src/Bridges/DatabaseDI/DatabaseExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@
'explain' => Expect::bool(true),
'reflection' => Expect::string(), // BC
'conventions' => Expect::string('discovered'), // Nette\Database\Conventions\DiscoveredConventions
'mapping' => Expect::structure([
'convention' => Expect::string(''),
'tables' => Expect::arrayOf('string', 'string'),
])->before(fn($v) => is_string($v) ? ['convention' => $v] : $v),
'autowired' => Expect::bool(),
]),
)->before(fn($val) => is_array(reset($val)) || reset($val) === null
Expand All @@ -62,7 +66,7 @@
foreach ($this->config as $name => $config) {
if ($config->debugger ?? $builder->getByType(Tracy\BlueScreen::class)) {
$connection = $builder->getDefinition($this->prefix("$name.connection"));
$connection->addSetup(

Check failure on line 69 in src/Bridges/DatabaseDI/DatabaseExtension.php

View workflow job for this annotation

GitHub Actions / PHPStan

Call to an undefined method Nette\DI\Definitions\Definition::addSetup().
[Nette\Bridges\DatabaseTracy\ConnectionPanel::class, 'initialize'],
[$connection, $this->debugMode, $name, !empty($config->explain)],
);
Expand Down Expand Up @@ -120,8 +124,15 @@
$conventions = Nette\DI\Helpers::filterArguments([$config->conventions])[0];
}

$rowMapping = ($config->mapping->convention || $config->mapping->tables)
? new Nette\DI\Definitions\Statement([self::class, 'createRowMapping'], [
$config->mapping->convention,
(array) $config->mapping->tables,
])
: null;

$builder->addDefinition($this->prefix("$name.explorer"))
->setFactory(Nette\Database\Explorer::class, [$connection, $structure, $conventions])
->setFactory(Nette\Database\Explorer::class, [$connection, $structure, $conventions, null, $rowMapping])
->setAutowired($config->autowired);

$builder->addAlias($this->prefix("$name.context"), $this->prefix("$name.explorer"));
Expand All @@ -132,4 +143,28 @@
$builder->addAlias("nette.database.$name.context", $this->prefix("$name.explorer"));
}
}


/**
* Creates a row mapping closure that resolves an ActiveRow subclass for each table name.
* @param array<string, string> $tables
* @return \Closure(string): string
*/
public static function createRowMapping(string $convention, array $tables): \Closure
{
return static function (string $table) use ($convention, $tables): string {
if (isset($tables[$table])) {
return $tables[$table];
}

if ($convention !== '') {
$class = str_replace('*', str_replace(' ', '', ucwords(strtr($table, '_', ' '))), $convention);
if (class_exists($class)) {
return $class;
}
}

return Nette\Database\Table\ActiveRow::class;
};
}
}
7 changes: 6 additions & 1 deletion src/Database/Explorer.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ public function __construct(
private readonly IStructure $structure,
?Conventions $conventions = null,
private readonly ?Nette\Caching\Storage $cacheStorage = null,
/** @var ?\Closure(string): class-string<Table\ActiveRow> */
private readonly ?\Closure $rowMapping = null,
) {
$this->conventions = $conventions ?: new StaticConventions;
}
Expand Down Expand Up @@ -121,7 +123,10 @@ public function getConventions(): Conventions
*/
public function createActiveRow(array $data, Table\Selection $selection): Table\ActiveRow
{
return new Table\ActiveRow($data, $selection);
$class = $this->rowMapping
? ($this->rowMapping)($selection->getName())
: Table\ActiveRow::class;
return new $class($data, $selection);
}


Expand Down
138 changes: 138 additions & 0 deletions tests/Database.DI/DatabaseExtension.rowMapping.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
<?php declare(strict_types=1);

/**
* Test: DatabaseExtension row mapping configuration.
*/

use Nette\Bridges\DatabaseDI\DatabaseExtension;
use Nette\DI;
use Tester\Assert;

require __DIR__ . '/../bootstrap.php';


test('string shortcut mapping', function () {
$loader = new DI\Config\Loader;
$config = $loader->load(Tester\FileMock::create('
database:
dsn: "sqlite::memory:"
mapping: App\Entity\*Row
debugger: no

services:
cache: Nette\Caching\Storages\DevNullStorage
', 'neon'));

$compiler = new DI\Compiler;
$compiler->addExtension('database', new DatabaseExtension(false));
$code = $compiler->addConfig($config)->setClassName('Container1')->compile();
eval($code);

$container = new Container1;
$container->initialize();

$explorer = $container->getService('database.default.explorer');
Assert::type(Nette\Database\Explorer::class, $explorer);

// verify the mapping closure was set by checking generated code
Assert::contains('createRowMapping', $code);
});


test('full mapping with convention and tables', function () {
$loader = new DI\Config\Loader;
$config = $loader->load(Tester\FileMock::create('
database:
dsn: "sqlite::memory:"
mapping:
convention: App\Entity\*Row
tables:
special: App\Entity\SpecialRow
debugger: no

services:
cache: Nette\Caching\Storages\DevNullStorage
', 'neon'));

$compiler = new DI\Compiler;
$compiler->addExtension('database', new DatabaseExtension(false));
$code = $compiler->addConfig($config)->setClassName('Container2')->compile();
eval($code);

$container = new Container2;
$container->initialize();

$explorer = $container->getService('database.default.explorer');
Assert::type(Nette\Database\Explorer::class, $explorer);
Assert::contains('createRowMapping', $code);
});


test('no mapping by default', function () {
$loader = new DI\Config\Loader;
$config = $loader->load(Tester\FileMock::create('
database:
dsn: "sqlite::memory:"
debugger: no

services:
cache: Nette\Caching\Storages\DevNullStorage
', 'neon'));

$compiler = new DI\Compiler;
$compiler->addExtension('database', new DatabaseExtension(false));
$code = $compiler->addConfig($config)->setClassName('Container3')->compile();
eval($code);

$container = new Container3;
$container->initialize();

$explorer = $container->getService('database.default.explorer');
Assert::type(Nette\Database\Explorer::class, $explorer);

// no mapping should be set
Assert::notContains('createRowMapping', $code);
});


test('createRowMapping() with convention', function () {
$mapping = DatabaseExtension::createRowMapping('App\Entity\*Row', []);

// unknown class -> fallback to ActiveRow
Assert::same(Nette\Database\Table\ActiveRow::class, $mapping('nonexistent'));
});


test('createRowMapping() with explicit tables', function () {
$mapping = DatabaseExtension::createRowMapping('', [
'my_table' => 'Nette\Database\Table\ActiveRow',
]);

Assert::same(Nette\Database\Table\ActiveRow::class, $mapping('my_table'));

// not in tables and no convention -> fallback
Assert::same(Nette\Database\Table\ActiveRow::class, $mapping('other'));
});


test('createRowMapping() tables override convention', function () {
$mapping = DatabaseExtension::createRowMapping('Some\*Row', [
'special' => 'Nette\Database\Table\ActiveRow',
]);

// explicit override wins
Assert::same(Nette\Database\Table\ActiveRow::class, $mapping('special'));
});


test('createRowMapping() snake_case to PascalCase', function () {
$mapping = DatabaseExtension::createRowMapping('*', []);

// We can't test actual entity classes (they don't exist in test env),
// but we can verify the fallback for non-existent classes
Assert::same(Nette\Database\Table\ActiveRow::class, $mapping('some_table'));

// Verify convention produces correct class names by using a class that exists
$mapping = DatabaseExtension::createRowMapping('Nette\Database\Table\*', []);
Assert::same('Nette\Database\Table\ActiveRow', $mapping('active_row'));
});
Loading