@@ -38,6 +38,12 @@ trait Model
3838 * @var string[]
3939 */
4040 protected ?array $ modelNamespaces = null ;
41+
42+ /**
43+ * Cached model column and mapped-attribute lookup tables.
44+ * @var array<class-string<ModelInterface>, array<string, bool>>
45+ */
46+ protected static array $ modelColumnCache = [];
4147
4248 /**
4349 * Retrieves the name of the model associated with the controller.
@@ -170,6 +176,43 @@ public function loadModel(?string $modelName = null): ModelInterface
170176 return $ this ->modelsManager ->load ($ modelName );
171177 }
172178
179+ /**
180+ * Determine whether the configured model exposes a database column or mapped
181+ * model attribute.
182+ *
183+ * The helper prefers generated model `columnMap()` definitions, then falls
184+ * back to Phalcon's model metadata for models that do not declare a column
185+ * map. Metadata availability depends on the application's configured
186+ * metadata strategy and cache; if metadata cannot be read safely, the helper
187+ * returns false instead of turning an optional controller condition into a
188+ * runtime failure.
189+ *
190+ * @param string $column Database column name or mapped model attribute name.
191+ * @param class-string<ModelInterface>|null $modelName Optional model class;
192+ * defaults to the current controller model.
193+ *
194+ * @return bool True when the model column map contains the raw column or
195+ * mapped attribute name.
196+ */
197+ public function modelHasColumn (string $ column , ?string $ modelName = null ): bool
198+ {
199+ if ($ column === '' ) {
200+ return false ;
201+ }
202+
203+ $ modelName ??= $ this ->getModelName ();
204+ if (!$ modelName || !is_a ($ modelName , ModelInterface::class, true )) {
205+ return false ;
206+ }
207+ /** @var class-string<ModelInterface> $modelName */
208+
209+ if (!isset (self ::$ modelColumnCache [$ modelName ])) {
210+ $ this ->cacheModelColumns ($ modelName );
211+ }
212+
213+ return self ::$ modelColumnCache [$ modelName ][$ column ] ?? false ;
214+ }
215+
173216 /**
174217 * Normalize and qualify a field reference with the model (alias) name.
175218 *
@@ -317,6 +360,80 @@ public function getPrimaryKeyAttributes(?string $modelName = null): array
317360 return $ this ->modelsMetadata ->getPrimaryKeyAttributes ($ this ->loadModel ($ modelName ));
318361 }
319362
363+ /**
364+ * @param class-string<ModelInterface> $modelName
365+ */
366+ protected function cacheModelColumns (string $ modelName ): void
367+ {
368+ $ model = $ this ->loadModel ($ modelName );
369+ $ lookup = [];
370+
371+ $ this ->collectModelColumnMap ($ lookup , $ this ->getGeneratedModelColumnMap ($ model ));
372+
373+ try {
374+ $ modelsMetadata = $ model ->getModelsMetaData ();
375+ $ this ->collectModelColumnMap ($ lookup , $ modelsMetadata ->getColumnMap ($ model ));
376+ $ this ->collectModelAttributes ($ lookup , $ modelsMetadata ->getAttributes ($ model ));
377+ } catch (\Throwable ) {
378+ // Metadata can be unavailable when a model has no initialized DI or
379+ // the configured adapter cannot read metadata for the model.
380+ }
381+
382+ self ::$ modelColumnCache [$ modelName ] = $ lookup ;
383+ }
384+
385+ /**
386+ * @return array<array-key, mixed>|null
387+ */
388+ protected function getGeneratedModelColumnMap (ModelInterface $ model ): ?array
389+ {
390+ if (!method_exists ($ model , 'columnMap ' )) {
391+ return null ;
392+ }
393+
394+ try {
395+ $ columnMap = call_user_func ([$ model , 'columnMap ' ]);
396+ } catch (\Throwable ) {
397+ return null ;
398+ }
399+
400+ return is_array ($ columnMap ) ? $ columnMap : null ;
401+ }
402+
403+ /**
404+ * @param array<string, bool> $lookup
405+ * @param array<array-key, mixed>|null $columnMap
406+ */
407+ protected function collectModelColumnMap (array &$ lookup , ?array $ columnMap ): void
408+ {
409+ if ($ columnMap === null ) {
410+ return ;
411+ }
412+
413+ foreach ($ columnMap as $ column => $ attribute ) {
414+ if (is_string ($ column )) {
415+ $ lookup [$ column ] = true ;
416+ }
417+
418+ if (is_string ($ attribute )) {
419+ $ lookup [$ attribute ] = true ;
420+ }
421+ }
422+ }
423+
424+ /**
425+ * @param array<string, bool> $lookup
426+ * @param array<array-key, mixed> $attributes
427+ */
428+ protected function collectModelAttributes (array &$ lookup , array $ attributes ): void
429+ {
430+ foreach ($ attributes as $ attribute ) {
431+ if (is_string ($ attribute )) {
432+ $ lookup [$ attribute ] = true ;
433+ }
434+ }
435+ }
436+
320437 protected function isExpression (string $ field ): bool
321438 {
322439 // contains parentheses OR SQL keywords that imply expression
0 commit comments