Skip to content

Commit 9835593

Browse files
committed
Add controller model column helper
1 parent dced1d2 commit 9835593

8 files changed

Lines changed: 331 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@ history, the old changelog, and committed file changes. Older Zemit-era entries
1515
are summarized where the commit history is too granular to be useful as
1616
release notes.
1717

18+
## 3.1.2 - 2026-06-11
19+
20+
### Added
21+
22+
- Added `modelHasColumn()` to REST controller model helpers so applications can
23+
conditionally compose model-backed behavior from generated column maps or
24+
Phalcon model metadata.
25+
1826
## 3.1.1 - 2026-06-11
1927

2028
### Added

src/Mvc/Controller/Traits/Abstracts/AbstractModel.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ abstract public function getModelNameFromController(?array $namespaces = null, s
2828
abstract public function getControllerName(): string;
2929

3030
abstract public function loadModel(?string $modelName = null): \Phalcon\Mvc\ModelInterface;
31+
32+
abstract public function modelHasColumn(string $column, ?string $modelName = null): bool;
3133

3234
abstract public function appendModelName(string $field, ?string $modelName = null): string;
3335
}

src/Mvc/Controller/Traits/Interfaces/ModelInterface.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,4 +68,19 @@ public function getControllerName(): string;
6868
* override.
6969
*/
7070
public function loadModel(?string $modelName = null): \Phalcon\Mvc\ModelInterface;
71+
72+
/**
73+
* Determine whether the configured model exposes a raw database column or a
74+
* mapped model attribute name.
75+
*
76+
* Implementations should use generated `columnMap()` definitions or Phalcon
77+
* model metadata rather than issuing application-level data queries. Models
78+
* without generated maps may depend on the application's configured metadata
79+
* strategy and cache.
80+
*
81+
* @param string $column Database column or mapped model attribute.
82+
* @param class-string<\Phalcon\Mvc\ModelInterface>|null $modelName Optional
83+
* model override; defaults to the current controller model.
84+
*/
85+
public function modelHasColumn(string $column, ?string $modelName = null): bool;
7186
}

src/Mvc/Controller/Traits/Model.php

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
/**
4+
* This file is part of the Phalcon Kit.
5+
*
6+
* (c) Phalcon Kit Team
7+
*
8+
* For the full copyright and license information, please view the LICENSE.txt
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace PhalconKit\Tests\Unit\Mvc\Controller\Traits\Fixtures;
15+
16+
use Phalcon\Mvc\Model;
17+
18+
final class ModelColumnAlternateModel extends Model
19+
{
20+
/**
21+
* @return array<string, string>
22+
*/
23+
public function columnMap(): array
24+
{
25+
return [
26+
'id' => 'id',
27+
'workspace_uuid' => 'workspaceUuid',
28+
];
29+
}
30+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
/**
4+
* This file is part of the Phalcon Kit.
5+
*
6+
* (c) Phalcon Kit Team
7+
*
8+
* For the full copyright and license information, please view the LICENSE.txt
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace PhalconKit\Tests\Unit\Mvc\Controller\Traits\Fixtures;
15+
16+
use Phalcon\Mvc\Model;
17+
18+
final class ModelColumnMappedModel extends Model
19+
{
20+
/**
21+
* @return array<string, string>
22+
*/
23+
public function columnMap(): array
24+
{
25+
return [
26+
'id' => 'id',
27+
'tenant' => 'tenantId',
28+
'created_at' => 'createdAt',
29+
];
30+
}
31+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
/**
4+
* This file is part of the Phalcon Kit.
5+
*
6+
* (c) Phalcon Kit Team
7+
*
8+
* For the full copyright and license information, please view the LICENSE.txt
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace PhalconKit\Tests\Unit\Mvc\Controller\Traits\Fixtures;
15+
16+
use Phalcon\Mvc\Model;
17+
use Phalcon\Mvc\Model\MetaDataInterface;
18+
19+
final class ModelColumnMetadataModel extends Model
20+
{
21+
public static ?MetaDataInterface $fakeModelsMetaData = null;
22+
23+
#[\Override]
24+
public function getModelsMetaData(): MetaDataInterface
25+
{
26+
return self::$fakeModelsMetaData ?? parent::getModelsMetaData();
27+
}
28+
}

tests/Unit/Mvc/Controller/Traits/ModelTraitTest.php

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,15 @@
1313

1414
namespace PhalconKit\Tests\Unit\Mvc\Controller\Traits;
1515

16+
use Phalcon\Mvc\ModelInterface;
1617
use PhalconKit\Di\FactoryDefault;
1718
use PhalconKit\Exception\ServiceException;
1819
use PhalconKit\Mvc\Controller\Restful;
1920
use PhalconKit\Tests\Unit\AbstractUnit;
21+
use PhalconKit\Tests\Unit\Mvc\Controller\Traits\Fixtures\ModelColumnAlternateModel;
22+
use PhalconKit\Tests\Unit\Mvc\Controller\Traits\Fixtures\ModelColumnMappedModel;
23+
use PhalconKit\Tests\Unit\Mvc\Controller\Traits\Fixtures\ModelColumnMetadataModel;
24+
use PhalconKit\Tests\Unit\Mvc\Model\Fixtures\FakeMetaData;
2025

2126
class ModelTraitTest extends AbstractUnit
2227
{
@@ -42,4 +47,99 @@ public function initialize(): void
4247

4348
$controller->getModelNamespaces();
4449
}
50+
51+
public function testModelHasColumnUsesCurrentControllerModelColumnMap(): void
52+
{
53+
$controller = $this->newModelColumnController(ModelColumnMappedModel::class);
54+
55+
$this->assertTrue($controller->modelHasColumn('tenant'));
56+
$this->assertTrue($controller->modelHasColumn('tenantId'));
57+
}
58+
59+
public function testModelHasColumnAcceptsExplicitModelName(): void
60+
{
61+
$controller = $this->newModelColumnController(ModelColumnMappedModel::class);
62+
63+
$this->assertTrue($controller->modelHasColumn('workspace_uuid', ModelColumnAlternateModel::class));
64+
$this->assertTrue($controller->modelHasColumn('workspaceUuid', ModelColumnAlternateModel::class));
65+
}
66+
67+
public function testModelHasColumnFallsBackToModelMetadataWhenColumnMapMethodIsMissing(): void
68+
{
69+
$controller = $this->newModelColumnController(ModelColumnMetadataModel::class);
70+
71+
$this->assertTrue($controller->modelHasColumn('legacy_code'));
72+
$this->assertTrue($controller->modelHasColumn('legacyCode'));
73+
}
74+
75+
public function testModelHasColumnReturnsFalseForMissingOrInvalidModel(): void
76+
{
77+
$controller = $this->newModelColumnController(null);
78+
79+
$this->assertFalse($controller->modelHasColumn('tenant'));
80+
$this->assertFalse($controller->modelHasColumn('tenant', \stdClass::class));
81+
}
82+
83+
public function testModelHasColumnReturnsFalseForUnknownColumn(): void
84+
{
85+
$controller = $this->newModelColumnController(ModelColumnMappedModel::class);
86+
87+
$this->assertFalse($controller->modelHasColumn('missing'));
88+
}
89+
90+
public function testModelHasColumnAcceptsRawColumnAndMappedAttributeNames(): void
91+
{
92+
$controller = $this->newModelColumnController(ModelColumnMappedModel::class);
93+
94+
$this->assertTrue($controller->modelHasColumn('created_at'));
95+
$this->assertTrue($controller->modelHasColumn('createdAt'));
96+
}
97+
98+
private function newModelColumnController(?string $modelName): Restful
99+
{
100+
$controller = new class extends Restful {
101+
/**
102+
* Disable normal REST initialization for this trait-focused test.
103+
*/
104+
public function initialize(): void
105+
{
106+
}
107+
108+
public function loadModel(?string $modelName = null): ModelInterface
109+
{
110+
$modelName ??= $this->getModelName();
111+
112+
if ($modelName === ModelColumnMappedModel::class) {
113+
return new ModelColumnMappedModel(null, $this->getDI());
114+
}
115+
116+
if ($modelName === ModelColumnAlternateModel::class) {
117+
return new ModelColumnAlternateModel(null, $this->getDI());
118+
}
119+
120+
if ($modelName === ModelColumnMetadataModel::class) {
121+
$metadata = new FakeMetaData();
122+
$metadata->attributes = ['id', 'legacy_code'];
123+
$metadata->fakeColumnMap = [
124+
'legacy_code' => 'legacyCode',
125+
];
126+
ModelColumnMetadataModel::$fakeModelsMetaData = $metadata;
127+
128+
return new ModelColumnMetadataModel(null, $this->getDI());
129+
}
130+
131+
throw new \LogicException('Unexpected model: ' . (string) $modelName);
132+
}
133+
};
134+
135+
$di = $this->di;
136+
if ($di === null) {
137+
self::fail('DI container was not initialized.');
138+
}
139+
140+
$controller->setDI($di);
141+
$controller->setModelName($modelName);
142+
143+
return $controller;
144+
}
45145
}

0 commit comments

Comments
 (0)