Skip to content

Commit 6170392

Browse files
committed
added Explorer::table(), ActiveRow::related() and ref() return type narrowing
1 parent aa966fd commit 6170392

13 files changed

+451
-0
lines changed

CLAUDE.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,22 @@ Extensions for specific Nette packages use dedicated namespaces: `Nette\PHPStan\
8585

8686
`AssertTypeNarrowingExtension` (`StaticMethodTypeSpecifyingExtension` + `TypeSpecifierAwareExtension`) narrows variable types after `Tester\Assert` assertion calls. Each assertion method is mapped to an equivalent PHP expression that PHPStan already understands, then delegated to `TypeSpecifier::specifyTypesInCondition()`. Supported methods: `null`, `notNull`, `true`, `false`, `truthy`, `falsey`, `same`, `notSame`, and `type` (with built-in type strings like `'string'`, `'int'`, etc. and class/interface names). Config: `extension-nette.neon`.
8787

88+
### TableRowTypeResolver
89+
90+
`TableRowTypeResolver` is a shared service used by the three database extensions below. It resolves database table names to entity row class types using a configurable convention mask (e.g. `App\Entity\*Row` where `*` is replaced by PascalCase table name) and optional explicit table-to-class overrides. Checks class existence via `ReflectionProvider`. Config: `extension-nette.neon` parameters `nette.database.mapping.convention` and `nette.database.mapping.tables`.
91+
92+
### ExplorerTableReturnTypeExtension
93+
94+
`ExplorerTableReturnTypeExtension` (`DynamicMethodReturnTypeExtension`) narrows return type of `Explorer::table()` from `Selection<ActiveRow>` to `Selection<EntityRow>` based on table-to-entity-class mapping. When the table name argument is a constant string and the resolved entity class exists, returns `GenericObjectType('Selection', [$rowType])`. Falls back to declared return type otherwise. Config: `extension-nette.neon`.
95+
96+
### ActiveRowRelatedReturnTypeExtension
97+
98+
`ActiveRowRelatedReturnTypeExtension` (`DynamicMethodReturnTypeExtension`) narrows return type of `ActiveRow::related()` from `GroupedSelection` to `GroupedSelection<EntityRow>`. Handles both plain table names and `table.column` format by extracting the table portion. Config: `extension-nette.neon`.
99+
100+
### ActiveRowRefReturnTypeExtension
101+
102+
`ActiveRowRefReturnTypeExtension` (`DynamicMethodReturnTypeExtension`) narrows return type of `ActiveRow::ref()` from `?self` to `?EntityRow`. Handles both plain table names and `table.column` format. Uses `TypeCombinator::addNull()` to preserve nullability. Config: `extension-nette.neon`.
103+
88104
### Testing
89105

90106
Tests use **Nette Tester** (not PHPUnit). Test files are `.phpt` in `tests/` with data files in `tests/data/`.

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
},
1818
"require-dev": {
1919
"nette/component-model": "^3.1",
20+
"nette/database": "^3.2",
2021
"nette/forms": "^3.2",
2122
"nette/schema": "^1.3",
2223
"nette/tester": "^2.6"

extension-nette.neon

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,47 @@ parameters:
66
- Nette\Security\SimpleIdentity
77
- Nette\Utils\Html
88

9+
nette:
10+
database:
11+
mapping:
12+
convention: ''
13+
tables: []
14+
15+
16+
parametersSchema:
17+
nette: structure([
18+
database: structure([
19+
mapping: structure([
20+
convention: string()
21+
tables: arrayOf(string(), string())
22+
])
23+
])
24+
])
25+
926

1027
services:
1128
# nette/component-model
1229
-
1330
class: Nette\PHPStan\ComponentModel\GetComponentReturnTypeExtension
1431
tags: [phpstan.broker.dynamicMethodReturnTypeExtension]
1532

33+
# nette/database
34+
nette.database.tableRowTypeResolver:
35+
class: Nette\PHPStan\Database\TableRowTypeResolver
36+
arguments:
37+
convention: %nette.database.mapping.convention%
38+
tables: %nette.database.mapping.tables%
39+
40+
-
41+
class: Nette\PHPStan\Database\ExplorerTableReturnTypeExtension
42+
tags: [phpstan.broker.dynamicMethodReturnTypeExtension]
43+
-
44+
class: Nette\PHPStan\Database\ActiveRowRelatedReturnTypeExtension
45+
tags: [phpstan.broker.dynamicMethodReturnTypeExtension]
46+
-
47+
class: Nette\PHPStan\Database\ActiveRowRefReturnTypeExtension
48+
tags: [phpstan.broker.dynamicMethodReturnTypeExtension]
49+
1650
# nette/forms
1751
-
1852
class: Nette\PHPStan\Forms\FormContainerReturnTypeExtension

readme.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,18 @@ includes:
4343

4444
**Precise return types** — narrows return types of `Strings::match()`, `matchAll()`, `split()`, `Helpers::falseToNull()`, `Expect::array()`, `Arrays::invoke()`, and `Arrays::invokeMethod()` based on the arguments you pass. Also narrows `Container::getComponent()` and `$container['...']` to match the corresponding `createComponent*()` factory return type. For forms, `$form['name']` returns the specific control type (e.g. `TextInput`, `SelectBox`) based on the `addText()`, `addSelect()`, etc. call in the same function.
4545

46+
**Database row mapping** — narrows return types of `Explorer::table()`, `ActiveRow::related()`, and `ActiveRow::ref()` based on a configurable table-to-entity-class convention. For example, `$explorer->table('booking')` returns `Selection<BookingRow>` instead of `Selection<ActiveRow>`. Configure via:
47+
48+
```neon
49+
parameters:
50+
nette:
51+
database:
52+
mapping:
53+
convention: App\Entity\*Row # * = PascalCase table name
54+
tables: # optional explicit overrides
55+
special_table: App\Entity\SpecialRow
56+
```
57+
4658
**Html magic methods** — resolves `$html->getXxx()`, `setXxx()`, and `addXxx()` calls on `Nette\Utils\Html` that go through `__call()` but aren't declared via `@method` annotations.
4759

4860
**Removes `|false` and `|null` from PHP functions** — many native functions like `getcwd`, `json_encode`, `preg_split`, `preg_replace`, and [many more](extension-php.neon) include `false` or `null` in their return type even though these error values are unrealistic on modern systems.
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Nette\PHPStan\Database;
4+
5+
use PhpParser\Node\Expr\MethodCall;
6+
use PHPStan\Analyser\Scope;
7+
use PHPStan\Reflection\MethodReflection;
8+
use PHPStan\Type\DynamicMethodReturnTypeExtension;
9+
use PHPStan\Type\Type;
10+
use PHPStan\Type\TypeCombinator;
11+
use function count;
12+
13+
14+
/**
15+
* Narrows return type of ActiveRow::ref() from ?self
16+
* to ?EntityRow based on table-to-entity-class mapping.
17+
*/
18+
class ActiveRowRefReturnTypeExtension implements DynamicMethodReturnTypeExtension
19+
{
20+
public function __construct(
21+
private TableRowTypeResolver $resolver,
22+
) {
23+
}
24+
25+
26+
public function getClass(): string
27+
{
28+
return 'Nette\Database\Table\ActiveRow';
29+
}
30+
31+
32+
public function isMethodSupported(MethodReflection $methodReflection): bool
33+
{
34+
return $methodReflection->getName() === 'ref';
35+
}
36+
37+
38+
public function getTypeFromMethodCall(
39+
MethodReflection $methodReflection,
40+
MethodCall $methodCall,
41+
Scope $scope,
42+
): ?Type
43+
{
44+
$args = $methodCall->getArgs();
45+
if ($args === []) {
46+
return null;
47+
}
48+
49+
$keyType = $scope->getType($args[0]->value);
50+
$constantStrings = $keyType->getConstantStrings();
51+
if (count($constantStrings) !== 1) {
52+
return null;
53+
}
54+
55+
$key = $constantStrings[0]->getValue();
56+
$tableName = $this->resolver->extractTableName($key);
57+
$rowType = $this->resolver->resolve($tableName);
58+
if ($rowType === null) {
59+
return null;
60+
}
61+
62+
return TypeCombinator::addNull($rowType);
63+
}
64+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Nette\PHPStan\Database;
4+
5+
use PhpParser\Node\Expr\MethodCall;
6+
use PHPStan\Analyser\Scope;
7+
use PHPStan\Reflection\MethodReflection;
8+
use PHPStan\Type\DynamicMethodReturnTypeExtension;
9+
use PHPStan\Type\Generic\GenericObjectType;
10+
use PHPStan\Type\Type;
11+
use function count;
12+
13+
14+
/**
15+
* Narrows return type of ActiveRow::related() from GroupedSelection<ActiveRow>
16+
* to GroupedSelection<EntityRow> based on table-to-entity-class mapping.
17+
*/
18+
class ActiveRowRelatedReturnTypeExtension implements DynamicMethodReturnTypeExtension
19+
{
20+
public function __construct(
21+
private TableRowTypeResolver $resolver,
22+
) {
23+
}
24+
25+
26+
public function getClass(): string
27+
{
28+
return 'Nette\Database\Table\ActiveRow';
29+
}
30+
31+
32+
public function isMethodSupported(MethodReflection $methodReflection): bool
33+
{
34+
return $methodReflection->getName() === 'related';
35+
}
36+
37+
38+
public function getTypeFromMethodCall(
39+
MethodReflection $methodReflection,
40+
MethodCall $methodCall,
41+
Scope $scope,
42+
): ?Type
43+
{
44+
$args = $methodCall->getArgs();
45+
if ($args === []) {
46+
return null;
47+
}
48+
49+
$keyType = $scope->getType($args[0]->value);
50+
$constantStrings = $keyType->getConstantStrings();
51+
if (count($constantStrings) !== 1) {
52+
return null;
53+
}
54+
55+
$key = $constantStrings[0]->getValue();
56+
$tableName = $this->resolver->extractTableName($key);
57+
$rowType = $this->resolver->resolve($tableName);
58+
if ($rowType === null) {
59+
return null;
60+
}
61+
62+
return new GenericObjectType('Nette\Database\Table\GroupedSelection', [$rowType]);
63+
}
64+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Nette\PHPStan\Database;
4+
5+
use PhpParser\Node\Expr\MethodCall;
6+
use PHPStan\Analyser\Scope;
7+
use PHPStan\Reflection\MethodReflection;
8+
use PHPStan\Type\DynamicMethodReturnTypeExtension;
9+
use PHPStan\Type\Generic\GenericObjectType;
10+
use PHPStan\Type\Type;
11+
use function count;
12+
13+
14+
/**
15+
* Narrows return type of Explorer::table() from Selection<ActiveRow>
16+
* to Selection<EntityRow> based on table-to-entity-class mapping.
17+
*/
18+
class ExplorerTableReturnTypeExtension implements DynamicMethodReturnTypeExtension
19+
{
20+
public function __construct(
21+
private TableRowTypeResolver $resolver,
22+
) {
23+
}
24+
25+
26+
public function getClass(): string
27+
{
28+
return 'Nette\Database\Explorer';
29+
}
30+
31+
32+
public function isMethodSupported(MethodReflection $methodReflection): bool
33+
{
34+
return $methodReflection->getName() === 'table';
35+
}
36+
37+
38+
public function getTypeFromMethodCall(
39+
MethodReflection $methodReflection,
40+
MethodCall $methodCall,
41+
Scope $scope,
42+
): ?Type
43+
{
44+
$args = $methodCall->getArgs();
45+
if ($args === []) {
46+
return null;
47+
}
48+
49+
$nameType = $scope->getType($args[0]->value);
50+
$constantStrings = $nameType->getConstantStrings();
51+
if (count($constantStrings) !== 1) {
52+
return null;
53+
}
54+
55+
$tableName = $constantStrings[0]->getValue();
56+
$rowType = $this->resolver->resolve($tableName);
57+
if ($rowType === null) {
58+
return null;
59+
}
60+
61+
return new GenericObjectType('Nette\Database\Table\Selection', [$rowType]);
62+
}
63+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Nette\PHPStan\Database;
4+
5+
use PHPStan\Reflection\ReflectionProvider;
6+
use PHPStan\Type\ObjectType;
7+
8+
9+
/**
10+
* Resolves database table names to entity row class types.
11+
* Convention: table_name -> PascalCase -> prepend prefix + append suffix from mask.
12+
* Explicit overrides in $tables take precedence over convention.
13+
*/
14+
class TableRowTypeResolver
15+
{
16+
private string $prefix;
17+
private string $suffix;
18+
19+
20+
/**
21+
* @param string $convention mask like App\Entity\*Row, where * is replaced by PascalCase table name
22+
* @param array<string, string> $tables explicit table -> FQCN overrides
23+
*/
24+
public function __construct(
25+
private ReflectionProvider $reflectionProvider,
26+
string $convention = '',
27+
private array $tables = [],
28+
) {
29+
if ($convention !== '' && str_contains($convention, '*')) {
30+
[$this->prefix, $this->suffix] = explode('*', $convention, 2);
31+
} else {
32+
$this->prefix = '';
33+
$this->suffix = '';
34+
}
35+
}
36+
37+
38+
/**
39+
* Resolves a table name to an ObjectType for the entity row class.
40+
* Returns null if no mapping applies (class does not exist).
41+
*/
42+
public function resolve(string $tableName): ?ObjectType
43+
{
44+
// 1. Explicit tables take precedence
45+
if (isset($this->tables[$tableName])) {
46+
$className = $this->tables[$tableName];
47+
return $this->reflectionProvider->hasClass($className)
48+
? new ObjectType($className)
49+
: null;
50+
}
51+
52+
// 2. Convention disabled when no mask configured
53+
if ($this->prefix === '' && $this->suffix === '') {
54+
return null;
55+
}
56+
57+
// 3. Convention: prefix + PascalCase(table) + suffix
58+
$className = $this->prefix . $this->snakeToPascalCase($tableName) . $this->suffix;
59+
return $this->reflectionProvider->hasClass($className)
60+
? new ObjectType($className)
61+
: null;
62+
}
63+
64+
65+
/**
66+
* Extracts the table name from a key parameter.
67+
* For related()/ref(), key can be 'table' or 'table.column'.
68+
*/
69+
public function extractTableName(string $key): string
70+
{
71+
$pos = strpos($key, '.');
72+
return $pos !== false ? substr($key, 0, $pos) : $key;
73+
}
74+
75+
76+
private function snakeToPascalCase(string $table): string
77+
{
78+
return str_replace(' ', '', ucwords(strtr($table, '_', ' ')));
79+
}
80+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php declare(strict_types=1);
2+
3+
use Nette\Database\Table\ActiveRow;
4+
use function PHPStan\Testing\assertType;
5+
6+
7+
class RefEventRow extends ActiveRow
8+
{
9+
}
10+
11+
12+
function testRef(ActiveRow $row): void
13+
{
14+
// Simple table name
15+
assertType('RefEventRow|null', $row->ref('ref_event'));
16+
17+
// With throughColumn
18+
assertType('RefEventRow|null', $row->ref('ref_event', 'event_id'));
19+
20+
// Unknown table -> falls back to declared return type
21+
assertType('Nette\Database\Table\ActiveRow|null', $row->ref('unknown'));
22+
}

0 commit comments

Comments
 (0)