diff --git a/src/generator/ApiGenerator.php b/src/generator/ApiGenerator.php index f930f949..a7eff510 100644 --- a/src/generator/ApiGenerator.php +++ b/src/generator/ApiGenerator.php @@ -7,6 +7,7 @@ namespace cebe\yii2openapi\generator; +use cebe\yii2openapi\lib\items\DbModel; use yii\db\mysql\Schema as MySqlSchema; use SamIT\Yii2\MariaDb\Schema as MariaDbSchema; use yii\db\pgsql\Schema as PgSqlSchema; @@ -133,6 +134,17 @@ class ApiGenerator extends Generator */ public $excludeModels = []; + /** + * @var array Map for custom dbModels + * + * @see DbModel::$scenarioDefaultDescription with acceptedInputs: {scenarioName}, {scenarioConst}, {modelName}. + * @example + * 'dbModel' => [ + * 'scenarioDefaultDescription' => "Scenario {scenarioName}", + * ] + */ + public $dbModel = []; + /** * @var array Map for custom controller names not based on model name for exclusive cases * @example diff --git a/src/generator/default/dbmodel.php b/src/generator/default/dbmodel.php index d9c60ebc..17fa4a84 100644 --- a/src/generator/default/dbmodel.php +++ b/src/generator/default/dbmodel.php @@ -11,14 +11,18 @@ +/** + * This file is generated by Gii, do not change manually! + */ + namespace ; /** - *description) ? '' : str_replace("\n", "\n * ", ' ' . trim($model->description)) ?> + *getModelClassDescription() ?> * dbAttributes() as $attribute): ?> - * @property getFormattedDescription() ?> + * @property getPropertyAnnotation() ?> * @@ -45,6 +49,16 @@ */ abstract class getClassName() ?> extends \yii\db\ActiveRecord { +getScenarios()): +foreach($scenarios as $scenario): ?> + /** + * + + */ + public const = ''; + + + virtualAttributes())):?> protected $virtualAttributes = ['columnName; @@ -62,6 +76,33 @@ public static function tableName() { return getTableAlias()) ?>; } + + + /** + * Automatically generated scenarios from the model 'x-scenarios'. + * @return array a list of scenarios and the corresponding active attributes. + */ + public function scenarios() + { + /** + * Each scenario is assigned attributes as in the 'default' scenario. + * The advantage is that the scenario can be used immediately. + * This can be overridden in the child model if needed. + */ + $default = parent::scenarios()[self::SCENARIO_DEFAULT]; + + return [ + + self:: => $default, + + /** + * The 'default' scenario and all scenarios mentioned in the rules() using 'on' and 'except' + * are automatically included in the scenarios() function for the model. + */ + ...parent::scenarios(), + ]; + } + virtualAttributes())):?> public function attributes() diff --git a/src/lib/AttributeResolver.php b/src/lib/AttributeResolver.php index 0063d8df..906226d4 100644 --- a/src/lib/AttributeResolver.php +++ b/src/lib/AttributeResolver.php @@ -60,7 +60,7 @@ class AttributeResolver /** * @var ComponentSchema */ - private $schema; + private $componentSchema; /** * @var \cebe\yii2openapi\lib\items\JunctionSchemas @@ -79,7 +79,7 @@ class AttributeResolver public function __construct(string $schemaName, ComponentSchema $schema, JunctionSchemas $junctions, ?Config $config = null) { $this->schemaName = $schemaName; - $this->schema = $schema; + $this->componentSchema = $schema; $this->tableName = $schema->resolveTableName($schemaName); $this->junctions = $junctions; $this->isJunctionSchema = $junctions->isJunctionSchema($schemaName); @@ -94,10 +94,10 @@ public function __construct(string $schemaName, ComponentSchema $schema, Junctio */ public function resolve():DbModel { - foreach ($this->schema->getProperties() as $property) { + foreach ($this->componentSchema->getProperties() as $property) { /** @var $property \cebe\yii2openapi\lib\openapi\PropertySchema */ - $isRequired = $this->schema->isRequiredProperty($property->getName()); + $isRequired = $this->componentSchema->isRequiredProperty($property->getName()); $nullableValue = $property->getProperty()->getSerializableData()->nullable ?? null; if ($nullableValue === false) { // see docs in README regarding NOT NULL, required and nullable $isRequired = true; @@ -113,18 +113,20 @@ public function resolve():DbModel } return Yii::createObject(DbModel::class, [ [ - 'pkName' => $this->schema->getPkName(), + /** @see \cebe\openapi\spec\Schema */ + 'openapiSchema' => $this->componentSchema->getSchema(), + 'pkName' => $this->componentSchema->getPkName(), 'name' => $this->schemaName, 'tableName' => $this->tableName, - 'description' => $this->schema->getDescription(), + 'description' => $this->componentSchema->getDescription(), 'attributes' => $this->attributes, 'relations' => $this->relations, 'nonDbRelations' => $this->nonDbRelations, 'many2many' => $this->many2many, - 'indexes' => $this->prepareIndexes($this->schema->getIndexes()), + 'indexes' => $this->prepareIndexes($this->componentSchema->getIndexes()), //For valid primary keys for junction tables 'junctionCols' => $this->isJunctionSchema ? $this->junctions->junctionCols($this->schemaName) : [], - 'isNotDb' => $this->schema->isNonDb(), + 'isNotDb' => $this->componentSchema->isNonDb(), ], ]); } @@ -185,7 +187,7 @@ protected function resolveHasMany2ManyTableProperty(PropertySchema $property, bo 'relatedSchemaName' => $junkAttribute['relatedClassName'], 'tableName' => $this->tableName, 'relatedTableName' => $junkAttribute['relatedTableName'], - 'pkAttribute' => $this->attributes[$this->schema->getPkName()], + 'pkAttribute' => $this->attributes[$this->componentSchema->getPkName()], 'hasViaModel' => true, 'viaModelName' => $viaModel, 'viaRelationName' => Inflector::id2camel($junkRef, '_'), @@ -197,7 +199,7 @@ protected function resolveHasMany2ManyTableProperty(PropertySchema $property, bo $this->relations[Inflector::pluralize($junkRef)] = Yii::createObject(AttributeRelation::class, [$junkRef, $junkAttribute['junctionTable'], $viaModel]) - ->asHasMany([$junkAttribute['pairProperty'] . '_id' => $this->schema->getPkName()]); + ->asHasMany([$junkAttribute['pairProperty'] . '_id' => $this->componentSchema->getPkName()]); return; } @@ -328,7 +330,7 @@ protected function resolveProperty( AttributeRelation::class, [$property->getName(), $relatedTableName, $relatedClassName] ) - ->asHasMany([$foreignPk => $this->schema->getPkName()]); + ->asHasMany([$foreignPk => $this->componentSchema->getPkName()]); return; } $relatedClassName = $property->getRefClassName(); @@ -347,10 +349,10 @@ protected function resolveProperty( AttributeRelation::class, [$property->getName(), $relatedTableName, $relatedClassName] ) - ->asHasMany([Inflector::camel2id($this->schemaName, '_') . '_id' => $this->schema->getPkName()]); + ->asHasMany([Inflector::camel2id($this->schemaName, '_') . '_id' => $this->componentSchema->getPkName()]); return; } - if ($this->schema->isNonDb() && $attribute->isReference()) { + if ($this->componentSchema->isNonDb() && $attribute->isReference()) { $this->attributes[$property->getName()] = $attribute; return; } @@ -398,7 +400,7 @@ protected function catchManyToMany( 'relatedSchemaName' => $relatedSchemaName, 'tableName' => $this->tableName, 'relatedTableName' => $relatedTableName, - 'pkAttribute' => $this->attributes[$this->schema->getPkName()], + 'pkAttribute' => $this->attributes[$this->componentSchema->getPkName()], ], ]); $this->many2many[$propertyName] = $relation; @@ -480,7 +482,7 @@ protected function resolvePropertyRef(PropertySchema $property, Attribute $attri $fkProperty = new PropertySchema( $property->getRefSchema()->getSchema(), $property->getName(), - $this->schema + $this->componentSchema ); [$min, $max] = $fkProperty->guessMinMax(); $attribute->setPhpType($fkProperty->guessPhpType()) diff --git a/src/lib/Config.php b/src/lib/Config.php index 60d15910..bd4934a0 100644 --- a/src/lib/Config.php +++ b/src/lib/Config.php @@ -109,6 +109,17 @@ class Config extends BaseObject */ public $excludeModels = []; + /** + * @var array Map for custom dbModels + * + * @see DbModel::$scenarioDefaultDescription with acceptedInputs: {scenarioName}, {scenarioConst}, {modelName}. + * @example + * 'dbModel' => [ + * 'scenarioDefaultDescription' => "Scenario {scenarioName}", + * ] + */ + public $dbModel = []; + /** * @var array Map for custom controller names not based on model name for exclusive cases * @example diff --git a/src/lib/generators/ModelsGenerator.php b/src/lib/generators/ModelsGenerator.php index 5f196525..27dc6aea 100644 --- a/src/lib/generators/ModelsGenerator.php +++ b/src/lib/generators/ModelsGenerator.php @@ -56,6 +56,9 @@ public function generate():CodeFiles } foreach ($this->models as $model) { $className = $model->getClassName(); + if (!empty($this->config->dbModel['scenarioDefaultDescription'])) { + $model->scenarioDefaultDescription = $this->config->dbModel['scenarioDefaultDescription']; + } if ($model->isNotDb === false) { $this->files->add(new CodeFile( Yii::getAlias("$modelPath/base/$className.php"), diff --git a/src/lib/helpers/FormatHelper.php b/src/lib/helpers/FormatHelper.php new file mode 100644 index 00000000..12567fe8 --- /dev/null +++ b/src/lib/helpers/FormatHelper.php @@ -0,0 +1,25 @@ + and contributors + * @license https://github.com/cebe/yii2-openapi/blob/master/LICENSE + */ + +namespace cebe\yii2openapi\lib\helpers; + +class FormatHelper +{ + /** + * @param string $description + * @param int $spacing + * @return string + */ + public static function getFormattedDescription(string $description, int $spacing = 1): string + { + $descriptionArr = explode("\n", trim($description)); + $descriptionArr = array_map(function ($item) { + return $item === '' ? '' : ' ' . $item; + }, $descriptionArr); + return implode("\n".str_repeat(" ", $spacing)."*", $descriptionArr); + } +} diff --git a/src/lib/items/Attribute.php b/src/lib/items/Attribute.php index 5563162d..5b62e49d 100644 --- a/src/lib/items/Attribute.php +++ b/src/lib/items/Attribute.php @@ -7,6 +7,7 @@ namespace cebe\yii2openapi\lib\items; +use cebe\yii2openapi\lib\helpers\FormatHelper; use yii\helpers\VarDumper; use \Yii; use cebe\yii2openapi\lib\openapi\PropertySchema; @@ -295,11 +296,16 @@ public function getMinLength():?int return $this->limits['minLength']; } - public function getFormattedDescription():string + /** + * @return string + */ + public function getPropertyAnnotation(): string { - $comment = $this->columnName.' '.$this->description; - $type = $this->phpType; - return $type.' $'.str_replace("\n", "\n * ", rtrim($comment)); + $annotation = $this->phpType . ' $' . $this->columnName; + if (!empty($this->description)) { + $annotation .= FormatHelper::getFormattedDescription($this->description); + } + return $annotation; } public function toColumnSchema():ColumnSchema diff --git a/src/lib/items/DbModel.php b/src/lib/items/DbModel.php index 4f174370..5c33c09b 100644 --- a/src/lib/items/DbModel.php +++ b/src/lib/items/DbModel.php @@ -7,6 +7,7 @@ namespace cebe\yii2openapi\lib\items; +use cebe\yii2openapi\lib\helpers\FormatHelper; use cebe\yii2openapi\lib\ValidationRulesBuilder; use Yii; use yii\base\BaseObject; @@ -27,6 +28,11 @@ */ class DbModel extends BaseObject { + /** + * @var \cebe\openapi\spec\Schema + */ + public $openapiSchema; + /** * @var string primary key attribute name */ @@ -76,6 +82,18 @@ class DbModel extends BaseObject public $isNotDb = false; + /** + * @var string + * Here, you can set your own default description for the scenario. + * AcceptedInputs: {scenarioName}, {scenarioConst}, {modelName}. + */ + public $scenarioDefaultDescription = "Scenario {scenarioName}"; + + /** + * @var array Automatically generated scenarios from the model 'x-scenarios'. + */ + private $_scenarios; + public function getTableAlias():string { return '{{%' . $this->tableName . '}}'; @@ -173,4 +191,130 @@ public function dbAttributes():array return !$attribute->isVirtual; }); } + + /** + * Returns a scenarios array based on the 'x-scenarios'. + * Each scenario has the following properties: 'name', 'const', and 'description'. + * + * When the `getScenarios` function is called for the first time on this model, + * the value is stored in `_scenarios` and then returned. + * If the `getScenariosByOpenapiSchema` function is called again on this model, + * the stored value from `_scenarios` is returned. + * + * @return array + */ + public function getScenarios(): array + { + if (isset($this->_scenarios)) { + return $this->_scenarios; + } + $this->_scenarios = $this->getScenariosByOpenapiSchema(); + return $this->_scenarios; + } + + /** + * Returns a scenarios array based on the 'x-scenarios'. + * Each scenario has the following properties: 'name', 'const', and 'description'. + * + * Example for 'schema.yaml': + * x-scenarios: + * - name: create + * description: My custom description for scenario create + * - name: update + * + * 1) With default @see $scenarioDefaultDescription = "Scenario {scenarioName}" + * + * The resulting array: + * [ + * [ + * 'name' => 'create', + * 'const' => 'SCENARIO_CREATE', + * 'description' => "My custom description for scenario create", + * ], + * [ + * 'name' => 'update', + * 'const' => 'SCENARIO_UPDATE', + * 'description' => "Scenario update", + * ], + * ] + * + * 2) With custom @see $scenarioDefaultDescription = implode("\n", [ + * "This Backend-Scenario \"{scenarioName}\" exist in both the frontend model and the backend model.", + * "@see \common\client\models\{modelName}::{scenarioConst}", + * ]); + * + * For the 'update' scenario, it is an example of a two-line description. + * E.g. your modelName is 'Project'. + * The resulting array: + * [ + * [ + * 'name' => 'create', + * 'const' => 'SCENARIO_CREATE', + * 'description' => "My custom description for scenario create", + * ], + * [ + * 'name' => 'update', + * 'const' => 'SCENARIO_UPDATE', + * 'description' => "This Backend-Scenario \"update\" exist in both the frontend model and the backend model.\n@see \common\client\models\Project::SCENARIO_UPDATE", + * ], + * ] + * + * @return array + */ + private function getScenariosByOpenapiSchema(): array + { + $x_scenarios = $this->openapiSchema->{'x-scenarios'} ?? []; + if (empty($x_scenarios) || !is_array($x_scenarios)) { + return []; + } + + $uniqueNames = []; + $scenarios = array_filter($x_scenarios, function ($scenario) use (&$uniqueNames) { + $name = $scenario['name'] ?? ''; + + // Check if the name is empty, already used, or does not meet the criteria + if ( + empty($name) || + in_array($name, $uniqueNames) || + !preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $name) + ) { + return false; // Exclude this item + } + + // Add the name to the uniqueNames array and keep the item + $uniqueNames[] = $name; + return true; + }); + + foreach ($scenarios as $key => $scenario) { + $scenarios[$key]['const'] = 'SCENARIO_' . strtoupper(implode('_', preg_split('/(?=[A-Z])/', $scenario['name']))); + $description = !empty($scenario['description']) ? + $scenario['description'] : $this->scenarioDefaultDescription; + $scenarios[$key]['description'] = FormatHelper::getFormattedDescription( + str_replace([ + '{scenarioName}', + '{scenarioConst}', + '{modelName}', + ], [ + $scenario['name'], + $scenarios[$key]['const'], + $this->name, + ], $description), + 5 + ); + } + + return $scenarios; + } + + /** + * @return string + */ + public function getModelClassDescription(): string + { + if (empty($this->description)) { + return ' This is the model class for table "'.$this->tableName.'".'; + } + return FormatHelper::getFormattedDescription($this->description); + } } diff --git a/tests/specs/blog/models/base/Category.php b/tests/specs/blog/models/base/Category.php index f22e0014..1c7cdb97 100644 --- a/tests/specs/blog/models/base/Category.php +++ b/tests/specs/blog/models/base/Category.php @@ -1,5 +1,9 @@ resolve(); $fixture = require Yii::getAlias('@fixtures/non-db.php'); $testModel = $fixture['personWatch']; + $testModel->openapiSchema = $model->openapiSchema; self::assertEquals($testModel, $model); } @@ -126,6 +127,7 @@ public function testResolveNonDbModel() $model = $resolver->resolve(); $fixture = require Yii::getAlias('@fixtures/non-db.php'); $testModel = $fixture['PetStatistic']; + $testModel->openapiSchema = $model->openapiSchema; self::assertEquals($testModel, $model); } }