Skip to content

Commit 9ede72e

Browse files
committed
Patch DB select return types and Redis mixin for Laravel
1 parent eafea0b commit 9ede72e

7 files changed

Lines changed: 304 additions & 45 deletions

File tree

docs/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
12+
- **`DB::select()` return type.** `DB::select()`, `DB::selectFromWriteConnection()`, and `DB::selectResultSets()` now return `array<int, stdClass>` instead of bare `array`, and `DB::selectOne()` returns `?stdClass` instead of `mixed`. Property access on query results (e.g. `$result[0]->column_name`) no longer produces false-positive diagnostics. The same fix applies to the underlying `Illuminate\Database\Connection` class.
13+
- **Redis `Connection` method resolution.** `Illuminate\Redis\Connections\Connection` now has `@mixin \Redis` applied automatically, so Redis commands like `del()`, `get()`, `set()`, etc. resolve through the phpredis stubs instead of producing false-positive diagnostics.
14+
1015
### Added
1116

1217
- **Foreach over arrays with non-class element types.** When iterating over a generic array whose value type is an array shape (e.g. `array<int, array{tool: Pen, count: int}>`), the foreach iteration variable now carries the full shape type. Bracket access on the iteration variable (`$entry['tool']->`) resolves each key to its declared type. Previously the element type was silently dropped when it was not a class name, leaving the iteration variable untyped.

docs/todo.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ within the same impact tier.
2727
| B11 | [`static` return type not resolved through `class-string<T>` context](todo/bugs.md#b11-static-return-type-not-resolved-through-class-stringt-context) | Low | Medium |
2828
| B12 | [`Collection::reduce()` generic return type not inferred](todo/bugs.md#b12-collectionreduce-generic-return-type-not-inferred) | Low | Medium |
2929
| B13 | [Array shape tracking from keyed literal assignments in loops](todo/bugs.md#b13-array-shape-tracking-from-keyed-literal-assignments-in-loops) | Low | High |
30-
| B14 | [`DB::select()` returns bare `array`, element type unresolvable](todo/bugs.md#b14-dbselect-returns-bare-array-element-type-unresolvable) | Low | Low |
3130
| H10 | [`return.unusedType` — remove unused type from return union](todo/phpstan-actions.md#h10-returnunusedtype--remove-unused-type-from-return-union) | Medium | Medium |
3231
| H6 | `return.type` — update return type to match actual returns | Medium | Medium |
3332
| | **Release 0.7.0** | | |

docs/todo/bugs.md

Lines changed: 0 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -136,41 +136,3 @@ unresolvable because the shape is lost.
136136
a basic array shape inference that preserves `array{key: Type}` from
137137
literal array constructors and propagates it through foreach.
138138

139-
---
140-
141-
## B14. `DB::select()` returns bare `array`, element type unresolvable
142-
**Impact: Low · Effort: Low**
143-
144-
Pattern:
145-
```php
146-
$result = DB::select('select sum(...) as incoming_qty from ...');
147-
if ($result[0] instanceof stdClass && $result[0]->incoming_qty) {
148-
return Convert::toInt($result[0]->incoming_qty); // unresolved
149-
}
150-
```
151-
152-
The `DB` facade's `@method` annotation declares `select()` as
153-
returning bare `array` rather than `array<int, stdClass>`. Since the
154-
return type has no element type, `$result[0]` is untyped and
155-
`->incoming_qty` is unresolvable.
156-
157-
Two compounding issues:
158-
159-
1. **Stub/facade return type gap:** `DB::select()` should return
160-
`array<int, stdClass>` (which is what the underlying
161-
`Connection::select()` actually returns), but the facade
162-
annotation only says `array`.
163-
164-
2. **`instanceof` narrowing on array element access:** Even if the
165-
array element type were known, `$result[0] instanceof stdClass`
166-
should narrow `$result[0]` to `stdClass` within the condition.
167-
The narrowing system only matches bare variable names (`$var`),
168-
not subscript expressions (`$var[0]`). This is a T20 concern.
169-
170-
**Observed in:** `PurchaseFileService:1087,1089`.
171-
172-
**Partial workaround:** PHPantom could patch `DB::select()` to return
173-
`array<int, stdClass>` via framework-specific return type overrides
174-
(similar to how scope methods are handled). The `instanceof`
175-
narrowing gap (issue 2) would remain but would no longer trigger
176-
here because `stdClass` property access is already suppressed.

docs/todo/type-inference.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -518,10 +518,12 @@ design.
518518
This fixed `Order:646,647` (`json_decode``mixed``is_object`
519519
guard → property access).
520520

521-
**Remaining:** `PurchaseFileService:1087,1089` — `$result[0]
522-
instanceof stdClass` where `$result` is bare `array` from
523-
`DB::select()`. This requires `instanceof` narrowing on array
524-
element access expressions, which is a T20 concern (the narrowing
525-
system only matches bare variable names, not `$arr[0]`).
521+
**Remaining:** `instanceof` narrowing on array element access
522+
expressions (T20 concern). The narrowing system only matches bare
523+
variable names (`$var`), not subscript expressions (`$arr[0]`).
524+
The `PurchaseFileService` case that originally motivated this item
525+
is now resolved by the `DB::select()` return type patch (B14) combined
526+
with `stdClass` property access suppression, but the general gap
527+
remains for any `$arr[$i] instanceof Foo` pattern.
526528

527529
---

src/virtual_members/laravel/patches.rs

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,26 @@
2929
//! patch runs at scope-injection time (post-generic-substitution),
3030
//! not during `resolve_class_fully_inner`. It is documented here
3131
//! as part of the patch inventory but not dispatched from this module.
32+
//!
33+
//! 4. **`Redis\Connections\Connection` mixin.**
34+
//! The base `Connection` class delegates all Redis commands to the
35+
//! underlying `\Redis` client via `__call`, but lacks a `@mixin`
36+
//! annotation. The patch injects `@mixin \Redis` **pre-resolution**
37+
//! (in `resolve_class_fully_inner`, before virtual member providers
38+
//! run) so that `collect_mixin_members` picks it up and merges
39+
//! `del()`, `get()`, `set()`, etc. from the stubs. This patch is
40+
//! not dispatched from `apply_laravel_patches` because that runs
41+
//! post-resolution, after mixin collection has already completed.
42+
//!
43+
//! 5. **`DB` facade / `Connection` select method return types.**
44+
//! The facade's `@method` annotations and the underlying
45+
//! `Connection` class both declare `select()`,
46+
//! `selectFromWriteConnection()`, and `selectResultSets()` as
47+
//! returning bare `array`. The actual return type is
48+
//! `array<int, stdClass>`. Similarly, `selectOne()` is declared as
49+
//! `mixed` but actually returns `stdClass|null`. The patch
50+
//! overrides these return types so that downstream property access
51+
//! on query results resolves correctly.
3252
3353
use crate::php_type::PhpType;
3454
use crate::types::ClassInfo;
@@ -38,6 +58,12 @@ use super::ELOQUENT_BUILDER_FQN;
3858
/// FQN of the `Conditionable` trait from `illuminate/support`.
3959
const CONDITIONABLE_FQN: &str = "Illuminate\\Support\\Traits\\Conditionable";
4060

61+
/// FQN of the `DB` facade from `illuminate/support`.
62+
const DB_FACADE_FQN: &str = "Illuminate\\Support\\Facades\\DB";
63+
64+
/// FQN of the base `Connection` class from `illuminate/database`.
65+
const DB_CONNECTION_FQN: &str = "Illuminate\\Database\\Connection";
66+
4167
/// Apply all registered Laravel class patches to a fully-resolved class.
4268
///
4369
/// Called from [`resolve_class_fully_inner`] after virtual members have
@@ -57,6 +83,10 @@ pub fn apply_laravel_patches(class: &mut ClassInfo, fqn: &str) {
5783
} else if fqn == CONDITIONABLE_FQN || class_uses_conditionable(class) {
5884
patch_conditionable_when_unless(class);
5985
}
86+
87+
if fqn == DB_FACADE_FQN || fqn == DB_CONNECTION_FQN {
88+
patch_db_select_return_types(class);
89+
}
6090
}
6191

6292
/// Override `__call` and `__callStatic` return types on Eloquent Builder
@@ -192,6 +222,51 @@ fn is_likely_template_param(ty: &PhpType) -> bool {
192222
false
193223
}
194224

225+
/// Patch `select()`, `selectFromWriteConnection()`, `selectResultSets()`
226+
/// return types from bare `array` to `array<int, stdClass>`, and
227+
/// `selectOne()` from `mixed` to `stdClass|null`.
228+
///
229+
/// Both the `DB` facade (`@method` annotations) and the underlying
230+
/// `Illuminate\Database\Connection` class declare these methods with
231+
/// imprecise return types. The actual runtime return is always an
232+
/// array of `stdClass` rows (or a single `stdClass|null` for
233+
/// `selectOne`). Patching this here lets property access on query
234+
/// results resolve correctly across the codebase.
235+
fn patch_db_select_return_types(class: &mut ClassInfo) {
236+
let array_of_std = PhpType::Generic(
237+
"array".to_string(),
238+
vec![
239+
PhpType::Named("int".to_string()),
240+
PhpType::Named("stdClass".to_string()),
241+
],
242+
);
243+
let std_or_null = PhpType::Nullable(Box::new(PhpType::Named("stdClass".to_string())));
244+
245+
for method in class.methods.make_mut().iter_mut() {
246+
match method.name.as_str() {
247+
"select" | "selectFromWriteConnection" | "selectResultSets" => {
248+
if method
249+
.return_type
250+
.as_ref()
251+
.is_some_and(|rt| rt.to_string() == "array")
252+
{
253+
method.return_type = Some(array_of_std.clone());
254+
}
255+
}
256+
"selectOne" => {
257+
if method
258+
.return_type
259+
.as_ref()
260+
.is_some_and(|rt| rt.to_string() == "mixed")
261+
{
262+
method.return_type = Some(std_or_null.clone());
263+
}
264+
}
265+
_ => {}
266+
}
267+
}
268+
}
269+
195270
/// Check whether a class uses the `Conditionable` trait (directly or
196271
/// through its trait list / parent chain markers).
197272
///

0 commit comments

Comments
 (0)