Skip to content

Commit 7661e52

Browse files
committed
nette/database row type narrowing (table/related/ref/insert)
Narrows return types based on a configurable table-to-entity-class mapping (nette.database.mapping.tables), which is not yet part of the nette/database distribution. Kept as the topmost commit so the whole feature can be excluded or rebased independently. Includes: - TableRowTypeResolver shared resolver - Explorer::table() -> Selection<EntityRow> - ActiveRow::related() -> GroupedSelection<EntityRow> - ActiveRow::ref() -> ?EntityRow - Selection::insert() -> mapped EntityRow
1 parent 7c65c77 commit 7661e52

16 files changed

Lines changed: 685 additions & 2 deletions

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"nette/application": "^3.2",
2020
"nette/assets": "^1.0",
2121
"nette/component-model": "^3.1",
22+
"nette/database": "^3.2",
2223
"nette/di": "^3.2",
2324
"nette/forms": "^3.2",
2425
"nette/schema": "^1.3",

docs/internals.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,12 +61,15 @@ trickiest, worth pointers rather than re-narration:
6161
`getXxx`/`setXxx`/`addXxx` magic methods on `Nette\Utils\Html` (they go through
6262
`__call`). `Html` is also a `universalObjectCratesClasses` entry — a second non-local
6363
touchpoint.
64+
- **Database family** — all delegate to the shared `TableRowTypeResolver` (wildcard
65+
table→entity mapping); `SelectionInsert` narrows only for a string-keyed array **and**
66+
a strict `ActiveRow` subtype.
6467
- **`Utils/ArraysInvokeTypeExtension`** — forwards args through
6568
`ParametersAcceptorSelector` to pick the right callable overload, `void``null`.
6669

6770
**Shared-helper seams are where drift bites:** `StringsRegexHelper` centralizes PREG
6871
flag mapping for the three Strings extensions *and* `ValidRegularExpressionRule`; the
69-
Assets families each have a shared resolver. And a **`Rule` receives raw,
72+
Database/Assets families each have a shared resolver. And a **`Rule` receives raw,
7073
non-normalized args** (unlike type extensions), so `ValidRegularExpressionRule` must
7174
resolve the pattern by name-then-position (`StringsRegexHelper::findArg`) or a named-arg
7275
reorder validates the wrong argument as a regex.
@@ -116,4 +119,4 @@ Registration is entirely NEON, layered: `extension.neon` (entry) → `extension-
116119
inert until tagged**; the shared services (`ComponentTreeResolver`, `StringsRegexHelper`,
117120
`TableRowTypeResolver`, `MapperTypeResolver`) are **untagged**, wired by constructor
118121
injection. Config-driven services carry their knowledge as NEON parameters with a
119-
`parametersSchema` (asset mapping, the RemoveFailing allowlist).
122+
`parametersSchema` (asset mapping, database table mapping, the RemoveFailing allowlist).

docs/reference.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,21 @@ A shipped feature, not test-only.
173173
`tryGetAsset()` → specific asset class; parses the qualified reference; `tryGetAsset()` adds
174174
`|null`. String refs only.
175175

176+
### Database family (`TableRowTypeResolver` shared service)
177+
178+
`TableRowTypeResolver` resolves table names → entity row class types from a `tables` map
179+
(single `*` wildcard, bare `*` catch-all; class-name `*` → PascalCase of the capture; exact keys
180+
beat wildcards, wildcards in declaration order; existence via `ReflectionProvider`). Mirrors
181+
`Nette\Database\DefaultEntityMapping`. Config parameter `nette.database.mapping.tables`.
182+
183+
- `ExplorerTableReturnTypeExtension` - `Explorer::table()``Selection<EntityRow>`.
184+
- `ActiveRowRelatedReturnTypeExtension` - `ActiveRow::related()``GroupedSelection<EntityRow>`
185+
(handles `table.column`).
186+
- `ActiveRowRefReturnTypeExtension` - `ActiveRow::ref()``?EntityRow` (preserves nullability).
187+
- `SelectionInsertReturnTypeExtension` - `Selection::insert()` → the concrete `EntityRow`, but
188+
only when the argument is a string-keyed array (single row) **and** the row type `T` is a
189+
strict subtype of `ActiveRow`. Bare `ActiveRow` keeps the honest union.
190+
176191
### InjectPropertyExtension
177192

178193
`ReadWritePropertiesExtension`. Treats `#[Nette\DI\Attributes\Inject]` properties as

extension-nette.neon

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,21 @@ parameters:
1919
nette:
2020
assets:
2121
mapping: []
22+
database:
23+
mapping:
24+
tables: []
2225

2326

2427
parametersSchema:
2528
nette: structure([
2629
assets: structure([
2730
mapping: arrayOf(string())
2831
])
32+
database: structure([
33+
mapping: structure([
34+
tables: arrayOf(string(), string())
35+
])
36+
])
2937
])
3038

3139

@@ -64,6 +72,24 @@ services:
6472
create: Nette\PHPStan\ComponentModel\GetComponentReturnTypeExtension
6573
tags: [phpstan.broker.dynamicMethodReturnTypeExtension]
6674

75+
# nette/database
76+
-
77+
create: Nette\PHPStan\Database\TableRowTypeResolver(
78+
tables: %nette.database.mapping.tables%
79+
)
80+
-
81+
create: Nette\PHPStan\Database\ExplorerTableReturnTypeExtension
82+
tags: [phpstan.broker.dynamicMethodReturnTypeExtension]
83+
-
84+
create: Nette\PHPStan\Database\ActiveRowRelatedReturnTypeExtension
85+
tags: [phpstan.broker.dynamicMethodReturnTypeExtension]
86+
-
87+
create: Nette\PHPStan\Database\ActiveRowRefReturnTypeExtension
88+
tags: [phpstan.broker.dynamicMethodReturnTypeExtension]
89+
-
90+
create: Nette\PHPStan\Database\SelectionInsertReturnTypeExtension
91+
tags: [phpstan.broker.dynamicMethodReturnTypeExtension]
92+
6793
# nette/di
6894
-
6995
create: Nette\PHPStan\DI\InjectPropertyExtension

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. For `Strings::match()` and `matchAll()` with a constant pattern, the exact array shape is derived from the regular expression — e.g. `Strings::match($s, '#(\d+)-(\w+)#')` returns `array{string, string, string}|null` with one element per capture group (named groups included). The same shape is inferred for the `$matches` argument of a `Strings::replace()` callback. 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()`, `ActiveRow::ref()`, and `Selection::insert()` based on a configurable table-to-entity-class convention. For example, `$explorer->table('booking')` returns `Selection<BookingRow>` instead of `Selection<ActiveRow>`, and a single-row `$bookings->insert([...])` returns `BookingRow` instead of the wide schema-dependent union. Configure via:
47+
48+
```neon
49+
parameters:
50+
nette:
51+
database:
52+
mapping:
53+
tables:
54+
*: App\Entity\*Row # * = PascalCase table name
55+
special_table: App\Entity\SpecialRow # optional explicit overrides
56+
```
57+
4658
**Asset type narrowing** — narrows return types of `Registry::getMapper()` to the specific mapper class, and `Registry::getAsset()` / `tryGetAsset()` to the specific asset type (e.g. `ImageAsset`, `ScriptAsset`) based on file extension. Also narrows `FilesystemMapper::getAsset()` and `ViteMapper::getAsset()` directly. Configure via:
4759

4860
```neon
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Nette\PHPStan\Database;
4+
5+
use Nette\Database\Table\ActiveRow;
6+
use PhpParser\Node\Expr;
7+
use PhpParser\Node\Expr\MethodCall;
8+
use PHPStan\Analyser\Scope;
9+
use PHPStan\Reflection\MethodReflection;
10+
use PHPStan\Type\DynamicMethodReturnTypeExtension;
11+
use PHPStan\Type\NullType;
12+
use PHPStan\Type\Type;
13+
use PHPStan\Type\TypeCombinator;
14+
use function count;
15+
16+
17+
/**
18+
* Narrows return type of ActiveRow::ref() from ?self
19+
* to ?EntityRow based on table-to-entity-class mapping.
20+
*
21+
* When the foreign-key column (2nd argument) is declared as non-nullable on the
22+
* calling row class, the referenced row is guaranteed to exist, so the result is
23+
* narrowed to a non-nullable EntityRow.
24+
*/
25+
class ActiveRowRefReturnTypeExtension implements DynamicMethodReturnTypeExtension
26+
{
27+
public function __construct(
28+
private readonly TableRowTypeResolver $resolver,
29+
) {
30+
}
31+
32+
33+
public function getClass(): string
34+
{
35+
return ActiveRow::class;
36+
}
37+
38+
39+
public function isMethodSupported(MethodReflection $methodReflection): bool
40+
{
41+
return $methodReflection->getName() === 'ref';
42+
}
43+
44+
45+
public function getTypeFromMethodCall(
46+
MethodReflection $methodReflection,
47+
MethodCall $methodCall,
48+
Scope $scope,
49+
): ?Type
50+
{
51+
if ($methodCall->isFirstClassCallable()) {
52+
return null;
53+
}
54+
55+
$args = $methodCall->getArgs();
56+
if ($args === []) {
57+
return null;
58+
}
59+
60+
$keyType = $scope->getType($args[0]->value);
61+
$constantStrings = $keyType->getConstantStrings();
62+
if (count($constantStrings) !== 1) {
63+
return null;
64+
}
65+
66+
$key = $constantStrings[0]->getValue();
67+
$tableName = $this->resolver->extractTableName($key);
68+
$rowType = $this->resolver->resolve($tableName);
69+
if ($rowType === null) {
70+
return null;
71+
}
72+
73+
if (isset($args[1]) && $this->isColumnNonNullable($args[1]->value, $methodCall->var, $scope)) {
74+
return $rowType;
75+
}
76+
77+
return TypeCombinator::addNull($rowType);
78+
}
79+
80+
81+
/**
82+
* Tells whether the FK column is declared as non-nullable on the calling row class.
83+
* Tries both camelCase (Explorer convention) and the raw column name.
84+
*/
85+
private function isColumnNonNullable(Expr $columnExpr, Expr $callerExpr, Scope $scope): bool
86+
{
87+
$columnStrings = $scope->getType($columnExpr)->getConstantStrings();
88+
if (count($columnStrings) !== 1) {
89+
return false;
90+
}
91+
92+
$column = $columnStrings[0]->getValue();
93+
$callerType = $scope->getType($callerExpr);
94+
95+
foreach ([$this->resolver->snakeToCamelCase($column), $column] as $property) {
96+
if (!$callerType->hasProperty($property)->yes()) {
97+
continue;
98+
}
99+
$propertyType = $callerType->getProperty($property, $scope)->getReadableType();
100+
return $propertyType->isSuperTypeOf(new NullType)->no();
101+
}
102+
103+
return false;
104+
}
105+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Nette\PHPStan\Database;
4+
5+
use Nette\Database\Table\ActiveRow;
6+
use Nette\Database\Table\GroupedSelection;
7+
use PhpParser\Node\Expr\MethodCall;
8+
use PHPStan\Analyser\Scope;
9+
use PHPStan\Reflection\MethodReflection;
10+
use PHPStan\Type\DynamicMethodReturnTypeExtension;
11+
use PHPStan\Type\Generic\GenericObjectType;
12+
use PHPStan\Type\Type;
13+
use function count;
14+
15+
16+
/**
17+
* Narrows return type of ActiveRow::related() from GroupedSelection<ActiveRow>
18+
* to GroupedSelection<EntityRow> based on table-to-entity-class mapping.
19+
*/
20+
class ActiveRowRelatedReturnTypeExtension implements DynamicMethodReturnTypeExtension
21+
{
22+
public function __construct(
23+
private readonly TableRowTypeResolver $resolver,
24+
) {
25+
}
26+
27+
28+
public function getClass(): string
29+
{
30+
return ActiveRow::class;
31+
}
32+
33+
34+
public function isMethodSupported(MethodReflection $methodReflection): bool
35+
{
36+
return $methodReflection->getName() === 'related';
37+
}
38+
39+
40+
public function getTypeFromMethodCall(
41+
MethodReflection $methodReflection,
42+
MethodCall $methodCall,
43+
Scope $scope,
44+
): ?Type
45+
{
46+
if ($methodCall->isFirstClassCallable()) {
47+
return null;
48+
}
49+
50+
$args = $methodCall->getArgs();
51+
if ($args === []) {
52+
return null;
53+
}
54+
55+
$keyType = $scope->getType($args[0]->value);
56+
$constantStrings = $keyType->getConstantStrings();
57+
if (count($constantStrings) !== 1) {
58+
return null;
59+
}
60+
61+
$key = $constantStrings[0]->getValue();
62+
$tableName = $this->resolver->extractTableName($key);
63+
$rowType = $this->resolver->resolve($tableName);
64+
if ($rowType === null) {
65+
return null;
66+
}
67+
68+
return new GenericObjectType(GroupedSelection::class, [$rowType]);
69+
}
70+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Nette\PHPStan\Database;
4+
5+
use Nette\Database\Explorer;
6+
use Nette\Database\Table\Selection;
7+
use PhpParser\Node\Expr\MethodCall;
8+
use PHPStan\Analyser\Scope;
9+
use PHPStan\Reflection\MethodReflection;
10+
use PHPStan\Type\DynamicMethodReturnTypeExtension;
11+
use PHPStan\Type\Generic\GenericObjectType;
12+
use PHPStan\Type\Type;
13+
use function count;
14+
15+
16+
/**
17+
* Narrows return type of Explorer::table() from Selection<ActiveRow>
18+
* to Selection<EntityRow> based on table-to-entity-class mapping.
19+
*/
20+
class ExplorerTableReturnTypeExtension implements DynamicMethodReturnTypeExtension
21+
{
22+
public function __construct(
23+
private readonly TableRowTypeResolver $resolver,
24+
) {
25+
}
26+
27+
28+
public function getClass(): string
29+
{
30+
return Explorer::class;
31+
}
32+
33+
34+
public function isMethodSupported(MethodReflection $methodReflection): bool
35+
{
36+
return $methodReflection->getName() === 'table';
37+
}
38+
39+
40+
public function getTypeFromMethodCall(
41+
MethodReflection $methodReflection,
42+
MethodCall $methodCall,
43+
Scope $scope,
44+
): ?Type
45+
{
46+
if ($methodCall->isFirstClassCallable()) {
47+
return null;
48+
}
49+
50+
$args = $methodCall->getArgs();
51+
if ($args === []) {
52+
return null;
53+
}
54+
55+
$nameType = $scope->getType($args[0]->value);
56+
$constantStrings = $nameType->getConstantStrings();
57+
if (count($constantStrings) !== 1) {
58+
return null;
59+
}
60+
61+
$tableName = $constantStrings[0]->getValue();
62+
$rowType = $this->resolver->resolve($tableName);
63+
if ($rowType === null) {
64+
return null;
65+
}
66+
67+
return new GenericObjectType(Selection::class, [$rowType]);
68+
}
69+
}

0 commit comments

Comments
 (0)