1616use CodeIgniter \PHPStan \Database \Schema \CastFieldTypeResolver ;
1717use CodeIgniter \PHPStan \Database \Schema \Column ;
1818use CodeIgniter \PHPStan \Database \Schema \ColumnTypeResolver ;
19+ use CodeIgniter \PHPStan \Database \Schema \Table ;
1920use CodeIgniter \PHPStan \Database \SchemaProvider ;
2021use CodeIgniter \PHPStan \NodeVisitor \ModelReturnTypeTransformVisitor ;
2122use PhpParser \Node \Expr ;
3637
3738/**
3839 * Resolves the type of a single fetched row for a model, honoring its `$returnType` (or the
39- * `asArray()`/`asObject()` override) and shaping array rows from the live columns and `$casts`.
40+ * `asArray()`/`asObject()` override), the `select()` field list, the live columns, and `$casts`.
4041 */
4142final class ModelFetchedReturnTypeHelper
4243{
@@ -52,11 +53,11 @@ public function getFetchedReturnType(ClassReflection $classReflection, ?MethodCa
5253 $ returnType = $ this ->resolveReturnType ($ classReflection , $ methodCall , $ scope );
5354
5455 if ($ returnType === 'object ' ) {
55- return $ this ->resolveObjectRowType ($ classReflection );
56+ return $ this ->resolveObjectRowType ($ classReflection, $ methodCall , $ scope );
5657 }
5758
5859 if ($ returnType === 'array ' ) {
59- return $ this ->resolveArrayRowType ($ classReflection );
60+ return $ this ->resolveArrayRowType ($ classReflection, $ methodCall , $ scope );
6061 }
6162
6263 if ($ this ->reflectionProvider ->hasClass ($ returnType )) {
@@ -85,9 +86,9 @@ private function resolveReturnType(ClassReflection $classReflection, ?MethodCall
8586 return is_string ($ returnType ) ? $ returnType : 'array ' ;
8687 }
8788
88- private function resolveArrayRowType (ClassReflection $ classReflection ): Type
89+ private function resolveArrayRowType (ClassReflection $ classReflection, ? MethodCall $ methodCall , Scope $ scope ): Type
8990 {
90- $ fields = $ this ->resolveRowFieldTypes ($ classReflection );
91+ $ fields = $ this ->resolveRowFieldTypes ($ classReflection, $ methodCall , $ scope );
9192
9293 if ($ fields === null ) {
9394 return new ArrayType (new StringType (), new MixedType ());
@@ -102,9 +103,9 @@ private function resolveArrayRowType(ClassReflection $classReflection): Type
102103 return $ builder ->getArray ();
103104 }
104105
105- private function resolveObjectRowType (ClassReflection $ classReflection ): Type
106+ private function resolveObjectRowType (ClassReflection $ classReflection, ? MethodCall $ methodCall , Scope $ scope ): Type
106107 {
107- $ fields = $ this ->resolveRowFieldTypes ($ classReflection );
108+ $ fields = $ this ->resolveRowFieldTypes ($ classReflection, $ methodCall , $ scope );
108109
109110 if ($ fields === null ) {
110111 return new ObjectType (stdClass::class);
@@ -114,9 +115,9 @@ private function resolveObjectRowType(ClassReflection $classReflection): Type
114115 }
115116
116117 /**
117- * @return array<string, Type>|null Null when the model's table cannot be resolved.
118+ * @return array<string, Type>|null Null when the row shape cannot be resolved.
118119 */
119- private function resolveRowFieldTypes (ClassReflection $ classReflection ): ?array
120+ private function resolveRowFieldTypes (ClassReflection $ classReflection, ? MethodCall $ methodCall , Scope $ scope ): ?array
120121 {
121122 $ tableName = $ classReflection ->getNativeReflection ()->getDefaultProperties ()['table ' ] ?? null ;
122123
@@ -128,10 +129,137 @@ private function resolveRowFieldTypes(ClassReflection $classReflection): ?array
128129
129130 $ casts = $ this ->readStringMap ($ classReflection , 'casts ' );
130131 $ castHandlers = $ this ->readStringMap ($ classReflection , 'castHandlers ' );
131- $ fields = [];
132+
133+ $ selects = $ methodCall !== null && $ methodCall ->hasAttribute (ModelReturnTypeTransformVisitor::SELECTS )
134+ ? $ methodCall ->getAttribute (ModelReturnTypeTransformVisitor::SELECTS )
135+ : null ;
136+
137+ if (! is_array ($ selects )) {
138+ return $ this ->columnsToFields ($ table , $ casts , $ castHandlers );
139+ }
140+
141+ return $ this ->resolveSelectedFields ($ selects , $ table , $ casts , $ castHandlers , $ scope );
142+ }
143+
144+ /**
145+ * @param array<mixed, mixed> $selects
146+ * @param array<string, string> $casts
147+ * @param array<string, string> $castHandlers
148+ *
149+ * @return array<string, Type>|null
150+ */
151+ private function resolveSelectedFields (array $ selects , Table $ table , array $ casts , array $ castHandlers , Scope $ scope ): ?array
152+ {
153+ $ fields = [];
154+
155+ foreach ($ selects as $ expr ) {
156+ if (! $ expr instanceof Expr) {
157+ return null ;
158+ }
159+
160+ $ strings = $ scope ->getType ($ expr )->getConstantStrings ();
161+
162+ if (count ($ strings ) !== 1 ) {
163+ return null ;
164+ }
165+
166+ foreach ($ this ->splitSelect ($ strings [0 ]->getValue ()) as $ part ) {
167+ $ resolved = $ this ->resolveSelectPart ($ part , $ table , $ casts , $ castHandlers );
168+
169+ if ($ resolved === null ) {
170+ return null ;
171+ }
172+
173+ foreach ($ resolved as $ name => $ type ) {
174+ $ fields [$ name ] = $ type ;
175+ }
176+ }
177+ }
178+
179+ return $ fields ;
180+ }
181+
182+ /**
183+ * @param array<string, string> $casts
184+ * @param array<string, string> $castHandlers
185+ *
186+ * @return array<string, Type>|null
187+ */
188+ private function resolveSelectPart (string $ part , Table $ modelTable , array $ casts , array $ castHandlers ): ?array
189+ {
190+ if ($ part === '* ' ) {
191+ return $ this ->columnsToFields ($ modelTable , $ casts , $ castHandlers );
192+ }
193+
194+ if (preg_match ('/^(\w+)\.\*$/ ' , $ part , $ matches ) === 1 ) {
195+ $ table = $ this ->schemaProvider ->get ()->getTable ($ matches [1 ]);
196+
197+ return $ table === null ? null : $ this ->columnsToFields ($ table , $ casts , $ castHandlers );
198+ }
199+
200+ if (preg_match ('/^(\w+)(?:\.(\w+))?(?:\s+(?:as\s+)?(\w+))?$/i ' , $ part , $ matches ) === 1 ) {
201+ $ qualified = ($ matches [2 ] ?? '' ) !== '' ;
202+ $ columnName = $ qualified ? $ matches [2 ] : $ matches [1 ];
203+ $ outputName = ($ matches [3 ] ?? '' ) !== '' ? $ matches [3 ] : $ columnName ;
204+ $ table = $ qualified ? $ this ->schemaProvider ->get ()->getTable ($ matches [1 ]) : $ modelTable ;
205+
206+ if ($ table === null ) {
207+ return null ;
208+ }
209+
210+ return [$ outputName => $ this ->fieldType ($ outputName , $ table ->getColumn ($ columnName ), $ casts , $ castHandlers )];
211+ }
212+
213+ if (preg_match ('/\bas\s+(\w+)\s*$/i ' , $ part , $ matches ) === 1 ) {
214+ return [$ matches [1 ] => $ this ->fieldType ($ matches [1 ], null , $ casts , $ castHandlers )];
215+ }
216+
217+ return null ;
218+ }
219+
220+ /**
221+ * @return list<string>
222+ */
223+ private function splitSelect (string $ select ): array
224+ {
225+ $ parts = [];
226+ $ current = '' ;
227+ $ depth = 0 ;
228+
229+ foreach (str_split ($ select === '' ? ' ' : $ select ) as $ char ) {
230+ if ($ char === '( ' ) {
231+ $ depth ++;
232+ } elseif ($ char === ') ' ) {
233+ $ depth --;
234+ }
235+
236+ if ($ char === ', ' && $ depth === 0 ) {
237+ $ parts [] = $ current ;
238+ $ current = '' ;
239+
240+ continue ;
241+ }
242+
243+ $ current .= $ char ;
244+ }
245+
246+ $ parts [] = $ current ;
247+
248+ return array_values (array_filter (array_map (trim (...), $ parts ), static fn (string $ part ): bool => $ part !== '' ));
249+ }
250+
251+ /**
252+ * @param array<string, string> $casts
253+ * @param array<string, string> $castHandlers
254+ *
255+ * @return array<string, Type>
256+ */
257+ private function columnsToFields (Table $ table , array $ casts , array $ castHandlers ): array
258+ {
259+ $ fields = [];
132260
133261 foreach ($ table ->columns as $ column ) {
134- $ fields [$ column ->name ] = $ this ->resolveFieldType ( $ column , $ casts , $ castHandlers );
262+ $ fields [$ column ->name ] = $ this ->fieldType ( $ column -> name , $ column , $ casts , $ castHandlers );
135263 }
136264
137265 return $ fields ;
@@ -141,13 +269,13 @@ private function resolveRowFieldTypes(ClassReflection $classReflection): ?array
141269 * @param array<string, string> $casts
142270 * @param array<string, string> $castHandlers
143271 */
144- private function resolveFieldType ( Column $ column , array $ casts , array $ castHandlers ): Type
272+ private function fieldType ( string $ name , ? Column $ column , array $ casts , array $ castHandlers ): Type
145273 {
146- if (isset ($ casts [$ column -> name ])) {
147- return $ this ->castFieldTypeResolver ->resolve ($ casts [$ column -> name ], $ castHandlers );
274+ if (isset ($ casts [$ name ])) {
275+ return $ this ->castFieldTypeResolver ->resolve ($ casts [$ name ], $ castHandlers );
148276 }
149277
150- return $ this ->columnTypeResolver ->resolve ($ column );
278+ return $ column !== null ? $ this ->columnTypeResolver ->resolve ($ column) : new MixedType ( );
151279 }
152280
153281 /**
0 commit comments