diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..5e4af88c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +/.idea +/.git +/tests/tmp/* +/vendor +/.dockerignore +/.editorconfig +/.env +/.env.dist +/.gitattributes +/.gitignore +/.php_cs.cache +/composer.lock +/Makefile \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index d319d22b..680b5701 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,9 +4,11 @@ php: - '7.1' - '7.2' - '7.3' - - nightly +# - '7.4' -install: make install +install: + - travis_retry composer self-update && composer --version + - travis_retry composer install --prefer-dist --no-interaction script: - make test - - if [[ $TRAVIS_PHP_VERSION = "7.3" || $TRAVIS_PHP_VERSION = "nightly" ]]; then true; else make check-style; fi + - if [[ $TRAVIS_PHP_VERSION = "7.3" || $TRAVIS_PHP_VERSION = "7.4" ]]; then true; else make check-style; fi diff --git a/Makefile b/Makefile index d7281b87..dad3cba8 100644 --- a/Makefile +++ b/Makefile @@ -18,5 +18,30 @@ install: test: php $(PHPARGS) vendor/bin/phpunit -.PHONY: all check-style fix-style install test +clean_all: + docker-compose down + sudo rm -rf tests/tmp/* + +clean: + sudo rm -rf tests/tmp/app/* + sudo rm -rf tests/tmp/docker_app/* + +up: + docker-compose up -d + +cli: + docker-compose exec php bash + +migrate: + mkdir -p "tests/tmp/app" + mkdir -p "tests/tmp/docker_app" + docker-compose run --rm php sh -c 'cd /app/tests && ./yii migrate --interactive=0' + +installdocker: + docker-compose run --rm php composer install && chmod +x tests/yii + +testdocker: + docker-compose run --rm php sh -c 'vendor/bin/phpunit tests/unit' + +.PHONY: all check-style fix-style install test clean clean_all up cli installdocker migrate testdocker diff --git a/composer.json b/composer.json index 73d4c05f..b0696706 100644 --- a/composer.json +++ b/composer.json @@ -19,9 +19,9 @@ }, "require": { "php": ">=7.1.0", - "cebe/php-openapi": "^1.5", + "cebe/php-openapi": "dev-wip-reference-cache as 1.5", "yiisoft/yii2": "~2.0.15", - "yiisoft/yii2-gii": "~2.0.0 | ~2.1.0", + "yiisoft/yii2-gii": "~2.0.0 | ~2.1.0| ~2.2.0", "fzaninotto/faker": "^1.8", "laminas/laminas-code": "^3.4" }, @@ -36,6 +36,11 @@ "cebe\\yii2openapi\\": "src/" } }, + "autoload-dev": { + "psr-4": { + "tests\\": "tests/" + } + }, "config": { "platform": { "php": "7.1.3" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..1c85402e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,68 @@ +version: "3.5" +services: + php: + build: + dockerfile: tests/docker/Dockerfile + context: . + volumes: + - ./tests/tmp/.composer:/root/.composer:rw + - .:/app + environment: + - TZ=UTC + - TIMEZONE=UTC + - DB_USER=dbuser + - DB_PASSWORD=dbpass + - IN_DOCKER=docker + depends_on: + - mysql + - postgres + - maria + tty: true + networks: + net: {} + mysql: + image: mysql:5.7 + ports: + - '13306:3306' + volumes: + - ./tests/tmp/mysql:/var/lib/mysql:rw + environment: + TZ: UTC + MYSQL_ALLOW_EMPTY_PASSWORD: 1 + MYSQL_USER: dbuser + MYSQL_PASSWORD: dbpass + MYSQL_DATABASE: testdb + networks: + net: {} + maria: + image: mariadb + ports: + - '23306:3306' + volumes: + - ./tests/tmp/maria:/var/lib/mysql:rw + environment: + TZ: UTC + MYSQL_ALLOW_EMPTY_PASSWORD: 1 + MYSQL_USER: dbuser + MYSQL_PASSWORD: dbpass + MYSQL_DATABASE: testdb + MYSQL_INITDB_SKIP_TZINFO: 1 + networks: + net: {} + postgres: + image: postgres:12 + ports: + - '15432:5432' + volumes: + - ./tests/tmp/postgres:/var/lib/postgresql/data:rw + environment: + TZ: UTC + PGTZ: UTC + POSTGRES_USER: dbuser + POSTGRES_PASSWORD: dbpass + POSTGRES_DB: testdb + networks: + net: {} + +networks: + net: {} diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 667ac5f3..fb71ca51 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -7,7 +7,7 @@ stopOnFailure="false"> - ./tests + ./tests/unit diff --git a/src/generator/ApiGenerator.php b/src/generator/ApiGenerator.php index 3e497d84..ed76e763 100644 --- a/src/generator/ApiGenerator.php +++ b/src/generator/ApiGenerator.php @@ -15,9 +15,9 @@ use cebe\openapi\spec\PathItem; use cebe\openapi\spec\Reference; use cebe\openapi\spec\Schema; -use cebe\yii2openapi\generator\helpers\DatabaseDiff; -use cebe\yii2openapi\generator\helpers\DbModel; -use cebe\yii2openapi\generator\helpers\SchemaToDatabase; +use cebe\yii2openapi\lib\items\DbModel; +use cebe\yii2openapi\lib\MigrationsGenerator; +use cebe\yii2openapi\lib\SchemaToDatabase; use Exception; use Laminas\Code\Generator\ClassGenerator; use Laminas\Code\Generator\FileGenerator; @@ -29,11 +29,12 @@ use yii\di\Instance; use yii\gii\CodeFile; use yii\gii\Generator; -use yii\helpers\ArrayHelper; use yii\helpers\FileHelper; use yii\helpers\Html; use yii\helpers\Inflector; use yii\helpers\StringHelper; +use function array_filter; +use const YII_ENV_TEST; /** * @@ -113,7 +114,7 @@ class ApiGenerator extends Generator public $migrationNamespace; - public $dbDiff = DatabaseDiff::class; + public $migrationGenerator = MigrationsGenerator::class; /** @@ -454,7 +455,7 @@ private function guessModelClass(Operation $operation, $actionName) $requestBody = $requestBody->resolve(); } foreach ($requestBody->content as $contentType => $content) { - list($modelClass, ) = $this->guessModelClassFromContent($content); + [$modelClass, ] = $this->guessModelClassFromContent($content); if ($modelClass !== null) { return $modelClass; } @@ -478,7 +479,7 @@ private function guessModelClass(Operation $operation, $actionName) $successResponse = $successResponse->resolve(); } foreach ($successResponse->content as $contentType => $content) { - list($modelClass, ) = $this->guessModelClassFromContent($content); + [$modelClass, ] = $this->guessModelClassFromContent($content); if ($modelClass !== null) { return $modelClass; } @@ -569,7 +570,7 @@ private function findResponseWrapper(Operation $operation, $actionName, $modelCl $successResponse = $successResponse->resolve(); } foreach ($successResponse->content as $contentType => $content) { - list($detectedModelClass, $itemWrapper, $itemsWrapper) = $this->guessModelClassFromContent($content); + [$detectedModelClass, $itemWrapper, $itemsWrapper] = $this->guessModelClassFromContent($content); if (($itemWrapper !== null || $itemsWrapper !== null) && $detectedModelClass === $modelClass) { return [$itemWrapper, $itemsWrapper]; } @@ -606,12 +607,17 @@ protected function generateControllers() return $c; } + /** + * @return DbModel[]|array + */ protected function generateModels() { - return (new SchemaToDatabase([ + $converter = Yii::createObject([ + 'class'=>SchemaToDatabase::class, 'excludeModels' => $this->excludeModels, 'generateModelsOnlyXTable' => $this->generateModelsOnlyXTable, - ]))->generateModels($this->getOpenApi()); + ]); + return $converter->generateModels($this->getOpenApi()); } /** @@ -673,7 +679,6 @@ public function generate() $classFileGenerator->generate() ); } - } } @@ -686,12 +691,8 @@ public function generate() $files[] = new CodeFile( Yii::getAlias("$modelPath/base/$className.php"), $this->render('dbmodel.php', [ - 'className' => $className, - 'tableName' => $model->tableName, + 'model' => $model, 'namespace' => $this->modelNamespace . '\\base', - 'description' => $model->description, - 'attributes' => $model->attributes, - 'relations' => $model->relations, 'relationNamespace' => $this->modelNamespace, ]) ); @@ -700,16 +701,14 @@ public function generate() $files[] = new CodeFile( Yii::getAlias("$fakerPath/{$className}Faker.php"), $this->render('faker.php', [ - 'className' => "{$className}Faker", - 'modelClass' => $className, + 'model' => $model, 'modelNamespace' => $this->modelNamespace, - 'namespace' => $this->fakerNamespace, - 'attributes' => $model->attributes, -// 'relations' => $model['relations'], + 'namespace' => $this->fakerNamespace ]) ); } } else { + /** This case not implemented yet, just keep it **/ $files[] = new CodeFile( Yii::getAlias("$modelPath/base/$className.php"), $this->render('model.php', [ @@ -736,7 +735,6 @@ public function generate() $classFileGenerator->generate() ); } - } } @@ -744,54 +742,25 @@ public function generate() if (!isset($models)) { $models = $this->generateModels(); } - /** @var $dbDiff DatabaseDiff */ - $dbDiff = Instance::ensure($this->dbDiff, DatabaseDiff::class); - $migrationFiles = []; - foreach ($models as $modelName => $model) { - - if (!($model instanceof DbModel)) { - continue; - } - - $tableName = $model->tableName; - list($upCode, $downCode, $dependencies, $migrationName) = $dbDiff->diffTable($tableName, $this->attributesToColumnSchemas($model->attributes), $model->relations); - if (empty($upCode) && empty($downCode)) { - continue; - } - - $migrationFiles[$tableName] = [ - 'dependencies' => $dependencies, - 'upCode' => $upCode, - 'downCode' => $downCode, - 'model' => $model, - 'modelName' => $modelName, - 'migrationName' => $migrationName, - ]; - } - - // sort files by dependecies of foreign keys - // TODO circular references could be solved by adding a separate migration which contains only foreign key changes - $migrationFiles = $this->sortByDependency($migrationFiles); + /** @var $migrationGenerator MigrationsGenerator */ + $migrationGenerator = Instance::ensure($this->migrationGenerator, MigrationsGenerator::class); + $migrationModels = $migrationGenerator->generate(array_filter($models, function ($model) { + return $model instanceof DbModel; + })); $migrationPath = Yii::getAlias($this->migrationPath); $migrationNamespace = $this->migrationNamespace; + $isTransactional = Yii::$app->db->getDriverName() === 'pgsql';//Probably some another yet // TODO start $i by looking at all files, otherwise only one generation per hours causes correct order!!! $i = 0; - foreach($migrationFiles as $tableName => $migrationFile) { - - // migration files get invalidated directly after generating - // if they contain a timestamp - // use fixed time here instead + foreach ($migrationModels as $tableName => $migration) { + // migration files get invalidated directly after generating, + // if they contain a timestamp use fixed time here instead do { - if ($migrationNamespace) { - $m = sprintf('%s%04d', date('ymdH'), $i); - $className = "M{$m}" . Inflector::id2camel($migrationFile['migrationName'], '_'); - } else { - $m = sprintf('%s%04d', date('ymd_H'), $i); - $className = "m{$m}_" . $migrationFile['migrationName']; - } + $date = YII_ENV_TEST ? '200000_00': ''; + $className = $migration->makeClassNameByTime($i, $migrationNamespace, $date); $i++; } while (file_exists(Yii::getAlias("$migrationPath/$className.php"))); @@ -799,14 +768,9 @@ public function generate() $files[] = new CodeFile( Yii::getAlias("$migrationPath/$className.php"), $this->render('migration.php', [ - 'className' => $className, + 'isTransactional' => $isTransactional, 'namespace' => $migrationNamespace, - 'tableName' => $tableName, - 'attributes' => $migrationFile['model']->attributes, - 'relations' => $migrationFile['model']->relations, - 'description' => 'Table for ' . $migrationFile['modelName'], - 'upCode' => $migrationFile['upCode'], - 'downCode' => $migrationFile['downCode'], + 'migration' => $migration ]) ); } @@ -815,88 +779,8 @@ public function generate() return $files; } - private $_sortResult = []; - - private function sortByDependency($migrationFiles) - { - $this->_sortResult = []; - - // sort alpabetically by name first - // to have consistent result in case no dependencies exist - ksort($migrationFiles); - - foreach ($migrationFiles as $name => $migrationFile) { - echo "adding $name\n"; - $this->sortByDependencyRecurse($name, $migrationFile, $migrationFiles); - } - return $this->_sortResult; - } - - private function sortByDependencyRecurse($name, $migrationFile, $migrationFiles) - { - if (!isset($this->_sortResult[$name])) { - $this->_sortResult[$name] = false; - foreach($migrationFile['dependencies'] as $dependency) { - if (!isset($migrationFiles[$dependency])) { - echo "skipping dep $dependency\n"; - continue; - } - echo "adding dep $dependency\n"; - $this->sortByDependencyRecurse($dependency, $migrationFiles[$dependency], $migrationFiles); - } - unset($this->_sortResult[$name]); - $this->_sortResult[$name] = $migrationFile; - } elseif ($this->_sortResult[$name] === false) { - throw new \Exception("A circular dependency is detected for table '$name'."); - } - } - private function getPathFromNamespace($namespace) { return Yii::getAlias('@' . str_replace('\\', '/', $namespace)); } - - private function attributesToColumnSchemas($attributes) - { - $columns = []; - foreach($attributes as $name => $attribute) { - $size = null; - if (isset($attribute['dbType']) && preg_match('~^string\((\d+)\)$~', $attribute['dbType'], $matches)) { - $size = $matches[1]; - $attribute['dbType'] = 'string'; - } - - $columns[$name] = new ColumnSchema([ - 'dbType' => $attribute['dbType'], - 'type' => $this->dbTypeToAbstractType($attribute['dbType']), - 'allowNull' => !$attribute['required'], - 'size' => $size, - // TODO add more fields - ]); - if ($columns[$name]->type === 'json') { - $columns[$name]->allowNull = false; - } - } - return $columns; - } - - private function dbTypeToAbstractType($type) - { - if (stripos($type, 'int') === 0) { - return 'integer'; - } - if (stripos($type, 'string') === 0) { - return 'string'; - } - if (stripos($type, 'varchar') === 0) { - return 'string'; - } - if (stripos($type, 'json') === 0) { - return 'json'; - } - if (stripos($type, 'datetime') === 0) { - return 'timestamp'; - } - return $type; - } } diff --git a/src/generator/default/dbmodel.php b/src/generator/default/dbmodel.php index 9e2adaf6..7e17c83b 100644 --- a/src/generator/default/dbmodel.php +++ b/src/generator/default/dbmodel.php @@ -1,98 +1,57 @@ + namespace ; /** - * + * description)) ?> * - - * @property $ +attributes as $attribute): ?> + * @property getFormattedDescription() ?> * - $relation): ?> - * @property \\ $ +relations as $relationName => $relation): ?> +isHasOne()):?> + * @property \\getClassName() ?> $ + + * @property array|\\getClassName() ?>[] $ + */ -abstract class extends \yii\db\ActiveRecord +abstract class name ?> extends \yii\db\ActiveRecord { public static function tableName() { - return ; + return getTableAlias()) ?>; } public function rules() { return [ - +getValidationRules()).",\n"?> ]; } - $relation): ?> - public function get() +relations as $relationName => $relation): ?> + public function getgetCamelName() ?>() { - return $this->(\\::class, getMethod() ?>(\\getClassName() ?>::class,', ', ]'], [', ', ' => ', ']'], - preg_replace('~\s+~', '', \yii\helpers\VarDumper::export($relation['link'])) + preg_replace('~\s+~', '', \yii\helpers\VarDumper::export($relation->getLink())) ) ?>); } - } diff --git a/src/generator/default/faker.php b/src/generator/default/faker.php index f40947dd..0ac7a5f5 100644 --- a/src/generator/default/faker.php +++ b/src/generator/default/faker.php @@ -1,3 +1,10 @@ + @@ -6,27 +13,33 @@ use Faker\Factory as FakerFactory; use Faker\UniqueGenerator; -use \; +use \name ?>; /** - * Fake data generator for + * Fake data generator for name ?> */ -class - +class name ?>Faker { public function generateModel() { $faker = FakerFactory::create(\Yii::$app->language); $uniqueFaker = new UniqueGenerator($faker); - $model = new ; -name ?>(); +attributes as $attribute): + if (!$attribute->fakerStub || $attribute->isReference()) { continue; } ?> - $model-> = ; + $model->columnName ?> = fakerStub ?>; +attributes as $attribute): + if (!$attribute->fakerStub || !$attribute->isReference()) { + continue; + } ?> + $model->columnName ?> = fakerStub ?>; + **/?> return $model; } } diff --git a/src/generator/default/migration.php b/src/generator/default/migration.php index 27ae9fc0..835e8b76 100644 --- a/src/generator/default/migration.php +++ b/src/generator/default/migration.php @@ -1,3 +1,10 @@ + /** - * + * getDescription() ?> */ -class extends \yii\db\Migration +class fileClassName ?> extends \yii\db\Migration { - public function up() + public function () { - +upCodeString) ?> } - public function down() + public function () { - +downCodeString) ?> } } diff --git a/src/generator/helpers/DatabaseDiff.php b/src/generator/helpers/DatabaseDiff.php deleted file mode 100644 index 27c86a66..00000000 --- a/src/generator/helpers/DatabaseDiff.php +++ /dev/null @@ -1,163 +0,0 @@ -db = Instance::ensure($this->db, Connection::class); - } - - /** - * Calculate the difference between a database table and the desired data schema. - * @param string $tableName name of the database table. - * @param ColumnSchema[] $columns - * @param array $relations - * @return array - */ - public function diffTable($tableName, $columns, $relations) - { - $tableSchema = $this->db->getTableSchema($tableName, true); - if ($tableSchema === null) { - // create table - $codeColumns = VarDumper::export(array_map(function ($c) { - return $this->columnToDbType($c); - }, $columns)); - $upCode = str_replace("\n", "\n ", " \$this->createTable('$tableName', $codeColumns);"); - $downCode = " \$this->dropTable('$tableName');"; - - $dependencies = []; - foreach($relations as $relation) { - if ($relation['method'] !== 'hasOne') { - continue; - } - $fkCol = reset($relation['link']); - $fkRefCol = key($relation['link']); - $fkRefTable = $relation['tableName']; - $fkName = $this->foreignKeyName($tableName, $fkCol, $fkRefTable, $fkRefCol); - $upCode .= "\n \$this->addForeignKey('$fkName', '$tableName', '$fkCol', '$fkRefTable', '$fkRefCol');"; - $downCode = " \$this->dropForeignKey('$fkName', '$tableName');\n$downCode"; - $dependencies[] = $fkRefTable; - } - - return [$upCode, $downCode, $dependencies, 'create_table_' . $this->normalizeTableName($tableName)]; - } - - $upCode = []; - $downCode = []; - - // compare existing columns with expected columns - $wantNames = array_keys($columns); - $haveNames = $tableSchema->columnNames; - sort($wantNames); - sort($haveNames); - $missingDiff = array_diff($wantNames, $haveNames); - $unknownDiff = array_diff($haveNames, $wantNames); - foreach ($missingDiff as $missingColumn) { - $upCode[] = "\$this->addColumn('$tableName', '$missingColumn', '{$this->escapeQuote($this->columnToDbType($columns[$missingColumn]))}');"; - $downCode[] = "\$this->dropColumn('$tableName', '$missingColumn');"; - } - foreach ($unknownDiff as $unknownColumn) { - $upCode[] = "\$this->dropColumn('$tableName', '$unknownColumn');"; - $oldDbType = $this->columnToDbType($tableSchema->columns[$unknownColumn]); - $downCode[] = "\$this->addColumn('$tableName', '$unknownColumn', '$oldDbType');"; - } - - // compare desired type with existing type - foreach ($tableSchema->columns as $columnName => $currentColumnSchema) { - if (!isset($columns[$columnName])) { - continue; - } - $desiredColumnSchema = $columns[$columnName]; - switch (true) { - case $desiredColumnSchema->dbType === 'pk': - case $desiredColumnSchema->dbType === 'bigpk': - // do not adjust existing primary keys - break; - case $desiredColumnSchema->type !== $currentColumnSchema->type: - case $desiredColumnSchema->allowNull != $currentColumnSchema->allowNull: - case $desiredColumnSchema->type === 'string' && $desiredColumnSchema->size != $currentColumnSchema->size: - $upCode[] = "\$this->alterColumn('$tableName', '$columnName', '{$this->escapeQuote($this->columnToDbType($desiredColumnSchema))}');"; - $downCode[] = "\$this->alterColumn('$tableName', '$columnName', '{$this->escapeQuote($this->columnToDbType($currentColumnSchema))}');"; - } - } - - // compare existing foreign keys with relations - $dependencies = []; - foreach($relations as $relation) { - if ($relation['method'] !== 'hasOne') { - continue; - } - $fkCol = reset($relation['link']); - $fkRefCol = key($relation['link']); - $fkRefTable = $relation['tableName']; - $fkName = $this->foreignKeyName($tableName, $fkCol, $fkRefTable, $fkRefCol); - - if (isset($tableSchema->foreignKeys[$fkName])) { - continue; - } - $upCode[] = "\$this->addForeignKey('$fkName', '$tableName', '$fkCol', '$fkRefTable', '$fkRefCol');"; - array_unshift($downCode, "\$this->dropForeignKey('$fkName', '$tableName');"); - $dependencies[] = $fkRefTable; - } - - if (empty($upCode) && empty($downCode)) { - return ['', '', [], '']; - } - - return [ - " " . implode("\n ", $upCode), - " " . implode("\n ", $downCode), - [], - 'change_table_' . $this->normalizeTableName($tableName), - ]; - } - - private function foreignKeyName($table, $column, $foreignTable, $foreignColumn) - { - $table = $this->normalizeTableName($table); - $foreignTable = $this->normalizeTableName($foreignTable); - return "fk_{$table}_{$column}_{$foreignTable}_{$foreignColumn}"; - } - - private function normalizeTableName($tableName) - { - if (preg_match('~^{{%?(.*)}}$~', $tableName, $m)) { - return $m[1]; - } - return $tableName; - } - - private function escapeQuote($str) - { - return str_replace("'", "\\'", $str); - } - - private function columnToDbType(ColumnSchema $column) - { - if ($column->dbType === 'pk') { - return $column->dbType; - } - return $column->dbType . ($column->size ? "({$column->size})" : '') . ($column->allowNull ? '' : ' NOT NULL'); - } -} diff --git a/src/generator/helpers/DbModel.php b/src/generator/helpers/DbModel.php deleted file mode 100644 index 78e19c5c..00000000 --- a/src/generator/helpers/DbModel.php +++ /dev/null @@ -1,32 +0,0 @@ -components->schemas as $schemaName => $schema) { - if ($schema instanceof Reference) { - $schema->getContext()->mode = ReferenceContext::RESOLVE_MODE_INLINE; - $schema = $schema->resolve(); - } - - // only generate tables for schemas of type object and those who have defined properties - if ((empty($schema->type) || $schema->type === 'object') && empty($schema->properties)) { - continue; - } - if (!empty($schema->type) && $schema->type !== 'object') { - continue; - } - // do not generate tables for composite schemas - if ($schema->allOf || $schema->anyOf || $schema->multipleOf || $schema->oneOf) { - continue; - } - // skip excluded model names - if (in_array($schemaName, $this->excludeModels)) { - continue; - } - - if ($this->generateModelsOnlyXTable && empty($schema->{'x-table'})) { - continue; - } - - list($attributes, $relations) = $this->generateAttributesAndRelations($schemaName, $schema); - - $models[$schemaName] = new DbModel([ - 'name' => $schemaName, - 'tableName' => '{{%' . ($schema->{'x-table'} ?? $this->generateTableName($schemaName)) . '}}', - 'description' => $schema->description, - 'attributes' => $attributes, - 'relations' => $relations, - ]); - - } - - // TODO generate hasMany relations and inverse relations - - return $models; - } - - /** - * Auto generate table name from model name. - * @param string $modelName - * @return string - */ - protected function generateTableName($schemaName) - { - return Inflector::camel2id(StringHelper::basename(Inflector::pluralize($schemaName)), '_'); - } - - protected function generateAttributesAndRelations($schemaName, Schema $schema) - { - $attributes = []; - $relations = []; - foreach ($schema->properties as $name => $property) { - - if ($property instanceof Reference) { - $refPointer = $property->getJsonReference()->getJsonPointer()->getPointer(); - $property->getContext()->mode = ReferenceContext::RESOLVE_MODE_ALL; - $resolvedProperty = $property->resolve(); - $dbName = "{$name}_id"; - $dbType = 'integer'; // for a foreign key - if (strpos($refPointer, '/components/schemas/') === 0) { - // relation - $type = substr($refPointer, 20); - $relations[$name] = [ - 'class' => $type, - 'method' => 'hasOne', - 'link' => ['id' => $dbName], // TODO pk may not be 'id' - 'tableName' => '{{%' . ($resolvedProperty->{'x-table'} ?? $this->generateTableName($type)) . '}}', - ]; - } else { - $type = $this->getSchemaType($resolvedProperty); - } - } else { - $resolvedProperty = $property; - $type = $this->getSchemaType($property); - $dbName = $name; - $dbType = $this->getDbType($name, $property); - } - - // relation - if (is_array($type)) { - $relations[$name] = [ - 'class' => $type[1], - 'method' => 'hasMany', - 'link' => [Inflector::camel2id($schemaName, '_') . '_id' => 'id'], // TODO pk may not be 'id' - ]; - $type = $type[0]; - continue; - } - - $attributes[$dbName] = [ - 'name' => $dbName, - 'type' => $type, - 'dbType' => $dbType, - 'dbName' => $dbName, - 'required' => false, - 'readOnly' => $resolvedProperty->readOnly ?? false, - 'description' => $resolvedProperty->description, - 'faker' => $this->guessModelFaker($name, $type, $resolvedProperty), - ]; - } - if (!empty($schema->required)) { - foreach ($schema->required as $property) { - if (!isset($attributes[$property])) { - continue; - } - $attributes[$property]['required'] = true; - } - } - return [$attributes, $relations]; - } - - - /** - * @param Schema $schema - * @return string|array - */ - protected function getSchemaType($schema) - { - switch ($schema->type) { - case 'integer': - return 'int'; - case 'boolean': - return 'bool'; - case 'number': // can be double and float - return 'float'; - case 'array': - if (isset($schema->items) && $schema->items instanceof Reference) { - $ref = $schema->items->getJsonReference()->getJsonPointer()->getPointer(); - if (strpos($ref, '/components/schemas/') === 0) { - return [substr($ref, 20) . '[]', substr($ref, 20)]; - } - } - // no break here - default: - return $schema->type; - } - } - - /** - * @param string $name - * @param Schema $schema - * @return string - * @link http://spec.openapis.org/oas/v3.0.3#data-types - */ - protected function getDbType($name, $schema) - { - if (isset($schema->{'x-db-type'})) { - return $schema->{'x-db-type'}; - } - - if ($name === 'id' && $schema->type === 'integer') { - return $schema->format === 'int64' ? 'bigpk' : 'pk'; - } - - switch ($schema->type) { - case 'string': - if (isset($schema->maxLength)) { - return 'string(' . ((int) $schema->maxLength) . ')'; - } - if ($schema->format === 'date') { - return 'date'; - } - if ($schema->format === 'date-time') { - return 'datetime'; - } - if ($schema->format === 'binary') { - return 'blob'; - } - - return 'text'; - case 'integer': - if ($schema->format === 'int64') { - return 'bigint'; - } - // no break - case 'boolean': - return $schema->type; - case 'number': // can be double and float - return $schema->format ?? 'float'; -// case 'array': -// // TODO array might refer to has_many relation -// if (isset($schema->items) && $schema->items instanceof Reference) { -// $ref = $schema->items->getJsonReference()->getJsonPointer()->getPointer(); -// if (strpos($ref, '/components/schemas/') === 0) { -// return substr($ref, 20) . '[]'; -// } -// } -// // no break here - default: - return 'text'; - } - } - - - /** - * Guess faker for attribute. - * @param string $name - * @param string $type - * @return string|null the faker PHP code or null. - * @link https://github.com/fzaninotto/Faker#formatters - */ - private function guessModelFaker($name, $type, Schema $property) - { - if (isset($property->{'x-faker'})) { - return $property->{'x-faker'}; - } - - $min = $max = null; - if (isset($property->minimum)) { - $min = $property->minimum; - if ($property->exclusiveMinimum) { - $min++; - } - } - if (isset($property->maximum)) { - $max = $property->maximum; - if ($property->exclusiveMaximum) { - $max++; - } - } - - switch ($type) { - case 'string': - if ($property->format === 'date') { - return '$faker->iso8601'; - } - if (!empty($property->enum) && is_array($property->enum)) { - return '$faker->randomElement('.var_export($property->enum, true).')'; - } - if ($name === 'title' && isset($property->maxLength) && $property->maxLength < 10) { - return '$faker->title'; - } - - $patterns = [ - '~_id$~' => '$uniqueFaker->numberBetween(0, 1000000)', - '~uuid$~' => '$uniqueFaker->uuid', - '~firstname~i' => '$faker->firstName', - '~(last|sur)name~i' => '$faker->lastName', - '~(company|employer)~i' => '$faker->company', - '~(city|town)~i' => '$faker->city', - '~(post|zip)code~i' => '$faker->postcode', - '~streetaddress~i' => '$faker->streetAddress', - '~address~i' => '$faker->address', - '~street~i' => '$faker->streetName', - '~state~i' => '$faker->state', - '~county~i' => 'sprintf("%s County", $faker->city)', - '~country~i' => '$faker->countryCode', - '~lang~i' => '$faker->languageCode', - '~locale~i' => '$faker->locale', - '~currency~i' => '$faker->currencyCode', - '~hash~i' => '$faker->sha256', - '~e?mail~i' => '$faker->safeEmail', - '~timestamp~i' => '$faker->unixTime', - '~.*At$~' => '$faker->dateTimeThisCentury->format(\'Y-m-d H:i:s\')', // createdAt, updatedAt, ... - '~.*ed_at$~i' => '$faker->dateTimeThisCentury->format(\'Y-m-d H:i:s\')', // created_at, updated_at, ... - '~(phone|fax|mobile|telnumber)~i' => '$faker->e164PhoneNumber', - '~(^lat|coord)~i' => '$faker->latitude', - '~^lon~i' => '$faker->longitude', - '~title~i' => '$faker->sentence', - '~(body|summary|article|content|descr|comment|detail)~i' => '$faker->paragraphs(6, true)', - '~(url|site|website)~i' => '$faker->url', - '~(username|login)~i' => '$faker->userName', - ]; - foreach ($patterns as $pattern => $faker) { - if (preg_match($pattern, $name)) { - if (isset($property->maxLength)) { - return 'substr(' . $faker . ', 0, ' . $property->maxLength . ')'; - } else { - return $faker; - } - } - } - - // TODO maybe also consider OpenAPI examples here - - if (isset($property->maxLength)) { - return 'substr($faker->text(' . $property->maxLength . '), 0, ' . $property->maxLength . ')'; - } - return '$faker->sentence'; - case 'int': - $fakerVariable = preg_match('~_?id$~', $name) ? 'uniqueFaker' : 'faker'; - - $maxInt = 2147483647; // 2^31-1 PHP_MAX_INT may be 64bit and too big in some cases - if ($min !== null && $max !== null) { - return "\${$fakerVariable}->numberBetween($min, $max)"; - } elseif ($min !== null) { - return "\${$fakerVariable}->numberBetween($min, $maxInt)"; - } elseif ($max !== null) { - return "\${$fakerVariable}->numberBetween(0, $max)"; - } - - $patterns = [ - '~timestamp~i' => '$faker->unixTime', - '~.*At$~' => '$faker->unixTime', // createdAt, updatedAt, ... - '~.*ed_at$~i' => '$faker->unixTime', // created_at, updated_at, ... - ]; - foreach ($patterns as $pattern => $faker) { - if (preg_match($pattern, $name)) { - return $faker; - } - } - - return "\${$fakerVariable}->numberBetween(0, $maxInt)"; - case 'bool': - return '$faker->boolean'; - case 'float': - if ($min !== null && $max !== null) { - return "\$faker->randomFloat(null, $min, $max)"; - } elseif ($min !== null) { - return "\$faker->randomFloat(null, $min)"; - } elseif ($max !== null) { - return "\$faker->randomFloat(null, 0, $max)"; - } - return '$faker->randomFloat()'; - } - - - return null; - } - -} diff --git a/src/lib/AttributeResolver.php b/src/lib/AttributeResolver.php new file mode 100644 index 00000000..7ddd20ad --- /dev/null +++ b/src/lib/AttributeResolver.php @@ -0,0 +1,225 @@ + and contributors + * @license https://github.com/cebe/yii2-openapi/blob/master/LICENSE + */ + +namespace cebe\yii2openapi\lib; + +use cebe\openapi\ReferenceContext; +use cebe\openapi\spec\Reference; +use cebe\openapi\spec\Schema; +use cebe\openapi\SpecObjectInterface; +use cebe\yii2openapi\lib\items\Attribute; +use cebe\yii2openapi\lib\items\AttributeRelation; +use cebe\yii2openapi\lib\items\DbModel; +use Yii; +use yii\helpers\Inflector; +use yii\helpers\StringHelper; +use function in_array; +use function str_replace; +use function strpos; +use function substr; + +class AttributeResolver +{ + private const REFERENCE_PATH = '/components/schemas/'; + private const REFERENCE_PATH_LEN = 20; + + /** + * @var Attribute[]|array + */ + private $attributes = []; + + /** + * @var AttributeRelation[]|array + */ + private $relations = []; + + /** + * @var string + */ + private $schemaName; + + /** + * @var \cebe\openapi\spec\Schema + */ + private $componentSchema; + + private $primaryKey; + + /** + * @var array|false|mixed|string + */ + private $tableName; + + public function __construct(string $schemaName, Schema $componentSchema) + { + $this->schemaName = $schemaName; + $this->componentSchema = $componentSchema; + $this->primaryKey = $componentSchema->{CustomSpecAttr::PRIMARY_KEY} ?? 'id'; + $this->tableName = $componentSchema->{CustomSpecAttr::TABLE} ?? self::tableNameBySchema($this->schemaName); + } + + /** + * @return \cebe\yii2openapi\lib\items\DbModel + */ + public function resolve():DbModel + { + $requiredProps = $this->componentSchema->required ?? []; + foreach ($this->componentSchema->properties as $propertyName => $property) { + $isRequired = in_array($propertyName, $requiredProps); + $this->resolveProperty($propertyName, $property, $isRequired); + } + return new DbModel([ + 'name' => $this->schemaName, + 'tableName' => $this->tableName, + 'description' => $this->componentSchema->description, + 'attributes' => $this->attributes, + 'relations' => $this->relations, + ]); + } + + protected static function tableNameBySchema(string $schemaName):string + { + return Inflector::camel2id(StringHelper::basename(Inflector::pluralize($schemaName)), '_'); + } + + protected function resolveProperty($propertyName, SpecObjectInterface $property, bool $isRequired) + { + $attribute = new Attribute($propertyName); + $attribute->setRequired($isRequired) + ->setDescription($property->description ?? '') + ->setReadOnly($property->readOnly ?? false) + ->setIsPrimary($propertyName === $this->primaryKey); + if ($property instanceof Reference) { + $refPointer = $property->getJsonReference()->getJsonPointer()->getPointer(); + $property->getContext()->mode = ReferenceContext::RESOLVE_MODE_ALL; + $relatedSchema = $property->resolve(); + if (strpos($refPointer, self::REFERENCE_PATH) === 0) { + if (strpos($refPointer, '/properties/')!==false) { + $attribute->asReference($this->schemaName); + $foreignPk = $this->componentSchema->{CustomSpecAttr::PRIMARY_KEY} ?? 'id'; + $foreignPkProperty = $this->componentSchema->properties[$foreignPk]; + $relatedTableName = $this->tableName; + $phpType = TypeResolver::schemaToPhpType($foreignPkProperty); + $attribute->setPhpType($phpType) + ->setDbType($this->guessDbType($foreignPkProperty, true, true)); + + $relation = (new AttributeRelation($propertyName, $relatedTableName, $this->schemaName)) + ->asHasOne([$foreignPk => $attribute->columnName])->asSelfReference(); + $this->relations[$propertyName] = $relation; + } else { + $relatedClassName = substr($refPointer, self::REFERENCE_PATH_LEN); + $relatedTableName = + $relatedSchema->{CustomSpecAttr::TABLE} ?? self::tableNameBySchema($relatedClassName); + $attribute->asReference($relatedClassName)->setDescription($relatedSchema->description ?? ''); + /** + * TODO: We need to detect primary key name of related column if it is not "id" + * So we should declare custom pk name in schema if it is not id + **/ + $foreignPk = $relatedSchema->{CustomSpecAttr::PRIMARY_KEY} ?? 'id'; + $foreignPkProperty = $relatedSchema->properties[$foreignPk]; + + $phpType = TypeResolver::schemaToPhpType($foreignPkProperty); + $attribute->setPhpType($phpType) + ->setDbType($this->guessDbType($foreignPkProperty, true, true)); + + $relation = (new AttributeRelation($propertyName, $relatedTableName, $relatedClassName)) + ->asHasOne([$foreignPk => $attribute->columnName]); + $this->relations[$propertyName] = $relation; + } + } else { + //TODO: This case for self-table relations, should be covered later +// $attribute->setPhpType( +// TypeResolver::schemaToPhpType($relatedSchema->type, $relatedSchema->format) +// ); + } + } + + if (!$attribute->isReference()) { + /**@var Schema $property */ + $phpType = TypeResolver::schemaToPhpType($property); + $attribute->setPhpType($phpType) + ->setDbType($this->guessDbType($property, ($propertyName === $this->primaryKey))) + ->setUnique($property->{CustomSpecAttr::UNIQUE} ?? false) + ->setSize($property->maxLength ?? null) + ->setDefault($property->default ?? null); + [$min, $max] = $this->guessMinMax($property); + $attribute->setLimits($min, $max, $property->minLength ?? null); + if (isset($property->enum) && is_array($property->enum)) { + $attribute->setEnumValues($property->enum); + } + } + + // has Many relation + $refPointer = $this->getHasManyReference($property); + if ($refPointer !== null) { + if (strpos($refPointer, '/properties/')!==false) { + $relatedClassName = $this->schemaName; + $relatedTableName = $this->tableName; + $foreignAttr = str_replace(self::REFERENCE_PATH.$relatedClassName.'/properties/', '', $refPointer); + $foreignPk = Inflector::camel2id($foreignAttr, '_') . '_id'; + $attribute->setPhpType($relatedClassName . '[]'); + $this->relations[$propertyName] = + (new AttributeRelation($propertyName, $relatedTableName, $relatedClassName)) + ->asHasMany([$foreignPk => $this->primaryKey]); + return; + } + $relatedClassName = substr($refPointer, self::REFERENCE_PATH_LEN); + $property->items->getContext()->mode = ReferenceContext::RESOLVE_MODE_ALL; + $relatedSchema = $property->items->resolve(); + $relatedTableName = + $relatedSchema->{CustomSpecAttr::TABLE} ?? self::tableNameBySchema($relatedClassName); +// $foreignPk = $relatedSchema->{CustomSpecAttr::PRIMARY_KEY} ?? 'id'; + $attribute->setPhpType($relatedClassName . '[]'); + $this->relations[$propertyName] = + (new AttributeRelation($propertyName, $relatedTableName, $relatedClassName)) + ->asHasMany([Inflector::camel2id($this->schemaName, '_') . '_id' => $this->primaryKey]); + return; + } + $this->attributes[$propertyName] = $attribute->setFakerStub($this->guessFakerStub($attribute, $property)); + } + + protected function getHasManyReference(SpecObjectInterface $property):?string + { + if ($property instanceof Reference) { + return null; + } + if ($property->type === 'array' && isset($property->items) && $property->items instanceof Reference) { + $ref = $property->items->getJsonReference()->getJsonPointer()->getPointer(); + if (strpos($ref, self::REFERENCE_PATH) === 0) { + return $ref; + } + } + return null; + } + + protected function guessMinMax(SpecObjectInterface $property):array + { + $min = $property->minimum ?? null; + $max = $property->maximum ?? null; + if ($min !== null && $property->exclusiveMinimum) { + $min++; //Need for ensure + } + if ($max !== null && $property->exclusiveMaximum) { + $max++; + } + return [$min, $max]; + } + + protected function guessFakerStub(Attribute $attribute, SpecObjectInterface $property):?string + { + $resolver = Yii::createObject(['class' => FakerStubResolver::class], [$attribute, $property]); + return $resolver->resolve(); + } + + protected function guessDbType(Schema $property, bool $isPk, bool $isReference = false):string + { + if ($isReference === true) { + return TypeResolver::referenceToDbType($property); + } + return TypeResolver::schemaToDbType($property, $isPk); + } +} diff --git a/src/lib/ColumnToCode.php b/src/lib/ColumnToCode.php new file mode 100644 index 00000000..27ff0061 --- /dev/null +++ b/src/lib/ColumnToCode.php @@ -0,0 +1,371 @@ + and contributors + * @license https://github.com/cebe/yii2-openapi/blob/master/LICENSE + */ + +namespace cebe\yii2openapi\lib; + +use yii\db\ArrayExpression; +use yii\db\ColumnSchema; +use yii\db\ColumnSchemaBuilder; +use yii\db\JsonExpression; +use yii\db\mysql\Schema as MySqlSchema; +use yii\db\pgsql\Schema as PgSqlSchema; +use yii\db\Schema; +use yii\helpers\StringHelper; +use function array_key_exists; +use function in_array; +use function is_array; +use function method_exists; +use function strpos; +use function strtolower; +use function substr; +use function trim; +use function ucfirst; +use const PHP_EOL; + +class ColumnToCode +{ + public const PK_TYPE_MAP = [ + Schema::TYPE_PK => 'primaryKey()', + Schema::TYPE_UPK => 'primaryKey()->unsigned()', + Schema::TYPE_BIGPK => 'bigPrimaryKey()', + Schema::TYPE_UBIGPK => 'bigPrimaryKey()->unsigned()', + ]; + + public const INT_TYPE_MAP = [ + Schema::TYPE_TINYINT => 'tinyInteger', + Schema::TYPE_SMALLINT => 'smallInteger', + Schema::TYPE_BIGINT => 'bigInteger', + ]; + + /** + * @var \yii\db\ColumnSchema + */ + private $column; + + /** + * @var \yii\db\Schema + */ + private $dbSchema; + + /** + * @var bool + */ + private $columnUnique; + + /** + * @var bool + */ + private $fromDb; + + /** + * @var bool + */ + private $typeOnly = false; + private $defaultOnly = false; + + /** + * ColumnToCode constructor. + * @param \yii\db\Schema $dbSchema + * @param \yii\db\ColumnSchema $column + * @param bool $columnUnique (Pass unique marker from schema, because ColumnSchema not contain it) + * @param bool $fromDb (if from database we prefer column type for usage, from schema - dbType) + */ + public function __construct(Schema $dbSchema, ColumnSchema $column, bool $columnUnique, bool $fromDb = false) + { + $this->dbSchema = $dbSchema; + $this->column = $column; + $this->columnUnique = $columnUnique; + $this->fromDb = $fromDb; + } + + public function resolveTypeOnly():string + { + $this->typeOnly = true; + return $this->resolve(); + } + + public function resolveDefaultOnly():string + { + $this->defaultOnly = true; + return $this->resolve(); + } + + public function resolve():string + { + $dbType = $this->column->dbType; + $type = $this->column->type; + //Primary Keys + if (array_key_exists($type, self::PK_TYPE_MAP)) { + return '$this->' . self::PK_TYPE_MAP[$type]; + } + if (array_key_exists($dbType, self::PK_TYPE_MAP)) { + return '$this->' . self::PK_TYPE_MAP[$dbType]; + } + if ($this->fromDb === true) { + $categoryType = (new ColumnSchemaBuilder(''))->categoryMap[$type] ?? ''; + } else { + $categoryType = (new ColumnSchemaBuilder(''))->categoryMap[$dbType] ?? ''; + } + + $columnTypeMethod = 'resolve' . ucfirst($categoryType) . 'Type'; + + if (StringHelper::startsWith($dbType, 'enum')) { + $columnTypeMethod = 'resolveEnumType'; + } + if (StringHelper::startsWith($dbType, 'set')) { + $columnTypeMethod = 'resolveSetType'; + } + if (StringHelper::startsWith($dbType, 'tsvector')) { + $columnTypeMethod = 'resolveTsvectorType'; + } + if (isset($column->dimension) && $column->dimension > 0) { + $columnTypeMethod = 'resolveArrayType'; + } + + if (method_exists($this, $columnTypeMethod)) { + return $this->$columnTypeMethod(); + } + + return $categoryType && !$this->defaultOnly? $this->resolveCommon() : $this->resolveRaw(); + } + + private function buildRawDefaultValue():string + { + $value = $this->column->defaultValue; + $nullable = $this->column->allowNull; + $isJson = in_array($this->column->dbType, ['json', 'jsonb']); + if ($value === null) { + return $nullable === true ? 'DEFAULT NULL' : ''; + } + switch (gettype($value)) { + case 'integer': + return 'DEFAULT ' . $value; + case 'object': + if ($value instanceof JsonExpression) { + return 'DEFAULT '.self::defaultValueJson($value->getValue()); + } + if ($value instanceof ArrayExpression) { + return 'DEFAULT '.self::defaultValueArray($value->getValue()); + } + return 'DEFAULT ' .(string) $value; + case 'double': + // ensure type cast always has . as decimal separator in all locales + return 'DEFAULT ' . str_replace(',', '.', (string)$value); + case 'boolean': + return 'DEFAULT ' . ($value ? 'TRUE' : 'FALSE'); + case 'array': + return $isJson? 'DEFAULT '.self::defaultValueJson($value) + :'DEFAULT '.self::defaultValueArray($value); + default: + if (stripos($value, 'NULL::') !== false) { + return 'DEFAULT NULL'; + } + return 'DEFAULT '.self::wrapQuotes($value); + } + } + + private static function defaultValueJson(array $value):string + { + return "'".\json_encode($value)."'"; + } + private static function defaultValueArray(array $value):string + { + return "'{".trim(\json_encode($value), '[]')."}'"; + } + public static function escapeQuotes(string $str):string + { + return str_replace(["'", '"', '$'], ["\\'", "\\'", '\$'], $str); + } + + public static function wrapQuotesOnlyRaw(string $code, bool $escapeQuotes = false):string + { + if (strpos($code, '$this->') === false) { + return $escapeQuotes ? '"' . self::escapeQuotes($code) . '"' : '"' . $code . '"'; + } + return $code; + } + + public static function wrapQuotes(string $str, string $quotes = "'", bool $escape = true):string + { + if ($escape && strpos($str, $quotes) !== false) { + return $quotes . self::escapeQuotes($str) . $quotes; + } + return $quotes . $str . $quotes; + } + + public static function enumToString(array $enum): string + { + $items = implode(",", array_map(function ($v) { + return self::wrapQuotes($v); + }, $enum)); + return self::escapeQuotes($items); + } + + private function resolveCommon():string + { + $size = $this->column->size ? '(' . $this->column->size . ')' : '()'; + $default = $this->buildDefaultValue(); + $nullable = $this->column->allowNull === true ? 'null()' : 'notNull()'; + if (array_key_exists($this->column->dbType, self::INT_TYPE_MAP)) { + $type = self::INT_TYPE_MAP[$this->column->dbType] . $size; + } elseif (array_key_exists($this->column->type, self::INT_TYPE_MAP)) { + $type = self::INT_TYPE_MAP[$this->column->type] . $size; + } else { + $type = $this->column->type . $size; + } + return $this->buildString($type, $default, $nullable); + } + + private function resolveRaw():string + { + $nullable = $this->column->allowNull ? 'NULL' : 'NOT NULL'; + $type = $this->column->dbType; + $default = $this->isDefaultAllowed() ? $this->buildRawDefaultValue(): ''; + if ($this->defaultOnly) { + return $default; + } + + $size = $this->column->size ? '(' . $this->column->size . ')' : ''; + $type = strpos($type, '(') === false ? $type . $size : $type; + if ($this->typeOnly === true) { + return $type; + } + $columns = $nullable . ($default ? ' ' . trim($default) : ''); + return $type . ' ' . $columns; + } + + private function resolveEnumType():string + { + if (!$this->column->enumValues || !is_array($this->column->enumValues)) { + return ''; + } + $default = $this->buildRawDefaultValue(); + if ($this->defaultOnly) { + return $default; + } + $nullable = $this->column->allowNull ? 'NULL' : 'NOT NULL'; + if ($this->isPostgres()) { + $type = $this->typeOnly + ? \sprintf('enum_%1$s USING %1$s::enum_%1$s', $this->column->name) + : 'enum_'.$this->column->name; + } else { + $values = array_map( + function ($v) { + return self::wrapQuotes($v); + }, + $this->column->enumValues + ); + $type = "enum(" . implode(', ', $values) . ")"; + } + if ($this->typeOnly === true) { + return $type; + } + $columns = $nullable . ($default ? ' ' . trim($default) : ''); + return $type . ' ' . $columns; + } + + private function resolveSetType():string + { + $default = $this->buildRawDefaultValue(); + if ($this->defaultOnly) { + return $default; + } + $type = $this->column->dbType; + $nullable = $this->column->allowNull ? 'NULL' : 'NOT NULL'; + $columns = $nullable . ($default ? ' ' . trim($default) : ''); + return $type . ' ' . $columns; + } + + private function resolveArrayType():string + { + $default = $this->buildDefaultValue(); + if ($this->defaultOnly) { + return $default; + } + $nullable = $this->column->allowNull === true ? 'null()' : 'notNull()'; + $type = $this->column->dbType; + return $this->buildString($type, $default, $nullable); + } + + private function resolveTsvectorType():string + { + //\var_dump($this->column); + return $this->resolveRaw(); + } + + private function buildDefaultValue():string + { + $value = $this->column->defaultValue; + if (!$this->isDefaultAllowed()) { + return ''; + } + if ($value === null) { + return ($this->column->allowNull === true)? 'defaultValue(null)' : ''; + } + + switch (gettype($value)) { + case 'integer': + return 'defaultValue(' . (int)$value . ')'; + case 'double': + case 'float': + // ensure type cast always has . as decimal separator in all locales + return 'defaultValue("' . str_replace(',', '.', (string)$value) . '")'; + case 'boolean': + return $value === true ? 'defaultValue(true)' : 'defaultValue(false)'; + case 'object': + if ($value instanceof JsonExpression) { + return 'defaultValue(' . json_encode($value->getValue()) . ')'; + } + return 'defaultExpression("' . self::escapeQuotes((string)$value) . '")'; + case 'array': + return (string)'defaultValue(' . json_encode($value) . ')'; + default: + { + if (stripos($value, 'NULL::') !== false) { + return ''; + } + if ( + StringHelper::startsWith($value, 'CURRENT') + || StringHelper::startsWith($value, 'LOCAL') + || substr($value, -1, 1) === ')') { + //TIMESTAMP MARKER OR DATABASE FUNCTION + return 'defaultExpression("' . self::escapeQuotes((string)$value) . '")'; + } + return 'defaultValue("' . self::escapeQuotes((string)$value) . '")'; + } + } + } + + private function buildString(string $type, $default = '', $nullable = ''):string + { + if ($this->typeOnly === true) { + $columnParts = [$type]; + } else { + $columnParts = [$type, $nullable, $default]; + if ($this->columnUnique) { + $columnParts[] = 'unique()'; + } + } + array_unshift($columnParts, '$this'); + return implode('->', array_filter(array_map('trim', $columnParts), 'trim')); + } + + private function isDefaultAllowed():bool + { + $type = strtolower($this->column->dbType); + if ($this->dbSchema instanceof MySqlSchema && in_array($type, ['blob', 'geometry', 'text', 'json'])) { + //Only mysql specific restriction, mariadb can it + return strpos($this->dbSchema->getServerVersion(), 'MariaDB') !== false; + } + return true; + } + + private function isPostgres():bool + { + return $this->dbSchema instanceof PgSqlSchema; + } +} diff --git a/src/lib/CustomSpecAttr.php b/src/lib/CustomSpecAttr.php new file mode 100644 index 00000000..8035864d --- /dev/null +++ b/src/lib/CustomSpecAttr.php @@ -0,0 +1,25 @@ + and contributors + * @license https://github.com/cebe/yii2-openapi/blob/master/LICENSE + */ + +namespace cebe\yii2openapi\lib; + +class CustomSpecAttr +{ + // --- For component schema --- + //Custom table name + public const TABLE = 'x-table'; + //Primary key property name, if it different from "id" (Only one value, compound keys not supported yet) + public const PRIMARY_KEY = 'x-pk'; + + // --- For each property schema --- + //Custom fake data for property + public const FAKER = 'x-faker'; + // Custom db type (MUST CONTAINS ONLY DB TYPE! (json, jsonb, uuid, varchar etc)) + public const DB_TYPE = 'x-db-type'; + // Flag for database unique constraint + public const UNIQUE = 'x-db-unique'; +} diff --git a/src/lib/FakerStubResolver.php b/src/lib/FakerStubResolver.php new file mode 100644 index 00000000..2a12dc51 --- /dev/null +++ b/src/lib/FakerStubResolver.php @@ -0,0 +1,178 @@ + and contributors + * @license https://github.com/cebe/yii2-openapi/blob/master/LICENSE + */ + +namespace cebe\yii2openapi\lib; + +use cebe\openapi\SpecObjectInterface; +use cebe\yii2openapi\lib\items\Attribute; + +/** + * Guess faker for attribute + * @link https://github.com/fzaninotto/Faker#formatters + **/ +class FakerStubResolver +{ + // 2^31-1 PHP_MAX_INT may be 64bit and too big in some cases + public const MAX_INT = 2147483647; + /** + * @var \cebe\yii2openapi\lib\items\Attribute + */ + private $attribute; + + /** + * @var \cebe\openapi\spec\Schema + */ + private $property; + + public function __construct(Attribute $attribute, SpecObjectInterface $property) + { + $this->attribute = $attribute; + $this->property = $property; + } + + public function resolve():?string + { + if (isset($this->property->{CustomSpecAttr::FAKER})) { + return $this->property->{CustomSpecAttr::FAKER}; + } + $limits = $this->attribute->limits; + switch ($this->attribute->phpType) { + case 'bool': + return '$faker->boolean'; + case 'int': + return $this->fakeForInt($limits['min'], $limits['max']); + case 'string': + return $this->fakeForString(); + case 'float': + case 'double': + return $this->fakeForFloat($limits['min'], $limits['max']); + case 'array': + return $this->fakeForArray($this->attribute); + default: + return null; + } + } + + private function fakeForString():?string + { + $formats = [ + 'date' => '$faker->iso8601', + 'date-time' => '$faker->dateTimeThisCentury->format(\'Y-m-d H:i:s\')', + 'email' => '$faker->safeEmail', + ]; + if ($this->property->format && isset($formats[$this->property->format])) { + return $formats[$this->property->format]; + } + if (!empty($this->property->enum) && is_array($this->property->enum)) { + return '$faker->randomElement(' . var_export($this->property->enum, true) . ')'; + } + if ($this->attribute->columnName === 'title' + && $this->attribute->size + && $this->attribute->size < 10) { + return '$faker->title'; + } + + $patterns = [ + '~_id$~' => '$uniqueFaker->numberBetween(0, 1000000)', + '~uuid$~' => '$uniqueFaker->uuid', + '~slug$~' => '$uniqueFaker->slug', + '~firstname~i' => '$faker->firstName', + '~password~i' => '$faker->password', + '~(last|sur)name~i' => '$faker->lastName', + '~(company|employer)~i' => '$faker->company', + '~(city|town)~i' => '$faker->city', + '~(post|zip)code~i' => '$faker->postcode', + '~streetaddress~i' => '$faker->streetAddress', + '~address~i' => '$faker->address', + '~street~i' => '$faker->streetName', + '~state~i' => '$faker->state', + '~county~i' => 'sprintf("%s County", $faker->city)', + '~country~i' => '$faker->countryCode', + '~lang~i' => '$faker->languageCode', + '~locale~i' => '$faker->locale', + '~currency~i' => '$faker->currencyCode', + '~(hash|token)~i' => '$faker->sha256', + '~e?mail~i' => '$faker->safeEmail', + '~timestamp~i' => '$faker->unixTime', + '~.*At$~' => '$faker->dateTimeThisCentury->format(\'Y-m-d H:i:s\')', // createdAt, updatedAt, ... + '~.*ed_at$~i' => '$faker->dateTimeThisCentury->format(\'Y-m-d H:i:s\')', // created_at, updated_at, ... + '~(phone|fax|mobile|telnumber)~i' => '$faker->e164PhoneNumber', + '~(^lat|coord)~i' => '$faker->latitude', + '~^lon~i' => '$faker->longitude', + '~title~i' => '$faker->sentence', + '~(body|summary|article|content|descr|comment|detail)~i' => '$faker->paragraphs(6, true)', + '~(url|site|website|href)~i' => '$faker->url', + '~(username|login)~i' => '$faker->userName', + ]; + $size = $this->attribute->size > 0 ? $this->attribute->size: null; + foreach ($patterns as $pattern => $fake) { + if (preg_match($pattern, $this->attribute->columnName)) { + if ($size) { + return 'substr(' . $fake . ', 0, ' . $size . ')'; + } + return $fake; + } + } + + // TODO maybe also consider OpenAPI examples here + + if ($size) { + return 'substr($faker->text(' . $size . '), 0, ' . $size . ')'; + } + return '$faker->sentence'; + } + + private function fakeForInt(?int $min, ?int $max):?string + { + $fakerVariable = 'faker'; + if (preg_match('~_?id$~', $this->attribute->columnName)) { + $fakerVariable = 'uniqueFaker'; + } + if ($min !== null && $max !== null) { + return "\${$fakerVariable}->numberBetween($min, $max)"; + } + + if ($min !== null) { + return "\${$fakerVariable}->numberBetween($min, ".self::MAX_INT.")"; + } + + if ($max !== null) { + return "\${$fakerVariable}->numberBetween(0, $max)"; + } + + $patterns = [ + '~timestamp~i' => '$faker->unixTime', + '~.*At$~' => '$faker->unixTime', // createdAt, updatedAt, ... + '~.*ed_at$~i' => '$faker->unixTime', // created_at, updated_at, ... + ]; + foreach ($patterns as $pattern => $fake) { + if (preg_match($pattern, $this->attribute->columnName)) { + return $fake; + } + } + return "\${$fakerVariable}->numberBetween(0, ".self::MAX_INT.")"; + } + + private function fakeForFloat(?int $min, ?int $max):?string + { + if ($min !== null && $max !== null) { + return "\$faker->randomFloat(null, $min, $max)"; + } + if ($min !== null) { + return "\$faker->randomFloat(null, $min)"; + } + if ($max !== null) { + return "\$faker->randomFloat(null, 0, $max)"; + } + return '$faker->randomFloat()'; + } + + private function fakeForArray(Attribute $attribute) + { + return '[]'; + } +} diff --git a/src/lib/MigrationBuilder.php b/src/lib/MigrationBuilder.php new file mode 100644 index 00000000..788a52f1 --- /dev/null +++ b/src/lib/MigrationBuilder.php @@ -0,0 +1,365 @@ + and contributors + * @license https://github.com/cebe/yii2-openapi/blob/master/LICENSE + */ + +namespace cebe\yii2openapi\lib; + +use cebe\yii2openapi\lib\items\DbModel; +use cebe\yii2openapi\lib\items\MigrationModel; +use yii\base\NotSupportedException; +use yii\db\ColumnSchema; +use yii\db\Connection; +use yii\helpers\VarDumper; +use function array_intersect; +use function array_map; +use function in_array; +use function sprintf; + +class MigrationBuilder +{ + public const INDENT = ' '; + private const ADD_TABLE = self::INDENT . "\$this->createTable('%s', %s);"; + private const DROP_TABLE = self::INDENT . "\$this->dropTable('%s');"; + private const ADD_COLUMN = self::INDENT . "\$this->addColumn('%s', '%s', %s);"; + private const DROP_COLUMN = self::INDENT . "\$this->dropColumn('%s', '%s');"; + private const ALTER_COLUMN = self::INDENT . "\$this->alterColumn('%s', '%s', %s);"; + private const ADD_FK = self::INDENT . "\$this->addForeignKey('%s', '%s', '%s', '%s', '%s');"; + private const DROP_FK = self::INDENT . "\$this->dropForeignKey('%s', '%s');"; + private const ADD_PK = self::INDENT . "\$this->addPrimaryKey('%s', '%s', '%s');"; + private const ADD_ENUM = self::INDENT . "\$this->execute('CREATE TYPE enum_%s AS ENUM(%s)');"; + private const DROP_ENUM = self::INDENT . "\$this->execute('DROP TYPE enum_%s');"; + private const ADD_UNIQUE = self::INDENT . "\$this->createIndex('%s', '%s', '%s', true);"; + private const DROP_INDEX = self::INDENT . "\$this->dropIndex('%s', '%s');"; + + /** + * @var \yii\db\Connection + */ + private $db; + + /** + * @var \cebe\yii2openapi\lib\items\DbModel + */ + private $model; + + /** + * @var \yii\db\TableSchema|null + */ + private $tableSchema; + + /**@var bool */ + private $isPostgres; + + /** + * @var MigrationModel $migration + **/ + private $migration; + + /** + * @var array + */ + private $uniqueColumns; + + /** + * @var array + */ + private $currentUniqueColumns; + + /** + * @var \yii\db\ColumnSchema[] + */ + private $newColumns; + + /** + * @var \yii\db\Schema + */ + private $dbSchema; + + public function __construct(Connection $db, DbModel $model) + { + $this->db = $db; + $this->model = $model; + $this->tableSchema = $db->getTableSchema($model->getTableAlias(), true); + $this->dbSchema = $db->getSchema(); + $this->isPostgres = $this->db->getDriverName() === 'pgsql'; + } + + public function build():MigrationModel + { + return $this->tableSchema === null ? $this->buildFresh() : $this->buildSecondary(); + } + + public function buildFresh():MigrationModel + { + $this->migration = new MigrationModel($this->model, true); + $this->uniqueColumns = $this->model->getUniqueColumnsList(); + $this->newColumns = $this->model->attributesToColumnSchema(); + $tableName = $this->model->getTableAlias(); + $codeColumns = VarDumper::export(array_map( + function (ColumnSchema $column) { + $isUnique = in_array($column->name, $this->uniqueColumns, true); + return $this->columnToCode($column, $isUnique, false); + }, + $this->model->attributesToColumnSchema() + )); + $codeColumns = str_replace(PHP_EOL, PHP_EOL . self::INDENT, $codeColumns); + $this->migration->addUpCode(sprintf(self::ADD_TABLE, $this->model->getTableAlias(), $codeColumns)) + ->addDownCode(sprintf(self::DROP_TABLE, $this->model->getTableAlias())); + if ($this->isPostgres) { + $enums = $this->model->getEnumAttributes(); + foreach ($enums as $attr) { + $items = ColumnToCode::enumToString($attr->enumValues); + $this->migration->addUpCode(sprintf(self::ADD_ENUM, $attr->columnName, $items), true) + ->addDownCode(sprintf(self::DROP_ENUM, $attr->columnName)); + } + } + foreach ($this->model->getHasOneRelations() as $relation) { + $fkCol = $relation->getColumnName(); + $refCol = $relation->getForeignName(); + $refTable = $relation->getTableAlias(); + $fkName = $this->foreignKeyName($this->model->tableName, $fkCol, $relation->getTableName(), $refCol); + $this->migration->addUpCode(sprintf(self::ADD_FK, $fkName, $tableName, $fkCol, $refTable, $refCol)) + ->addDownCode(sprintf(self::DROP_FK, $fkName, $tableName)); + if ($relation->getTableName() !== $this->model->tableName) { + $this->migration->dependencies[] = $refTable; + } + } + + return $this->migration; + } + + public function buildSecondary():MigrationModel + { + $this->migration = new MigrationModel($this->model, false); + $this->uniqueColumns = $this->model->getUniqueColumnsList(); + $this->currentUniqueColumns = array_values($this->findUniqueIndexes()); + $this->newColumns = $this->model->attributesToColumnSchema(); + $wantNames = array_keys($this->newColumns); + $haveNames = $this->tableSchema->columnNames; + sort($wantNames); + sort($haveNames); + $columnsForCreate = array_map( + function (string $missingColumn) { + return $this->newColumns[$missingColumn]; + }, + array_diff($wantNames, $haveNames) + ); + + $columnsForDrop = array_map( + function (string $unknownColumn) { + return $this->tableSchema->columns[$unknownColumn]; + }, + array_diff($haveNames, $wantNames) + ); + + $columnsForChange = array_intersect($wantNames, $haveNames); + + $this->buildColumnsCreation($columnsForCreate); + $this->buildColumnsDrop($columnsForDrop); + foreach ($columnsForChange as $commonColumn) { + $this->buildColumnChanges($this->tableSchema->columns[$commonColumn], $this->newColumns[$commonColumn]); + } + + $this->buildRelations(); + return $this->migration; + } + + /** + * @param array|\yii\db\ColumnSchema[] $columns + */ + private function buildColumnsCreation(array $columns):void + { + foreach ($columns as $column) { + if ($column->isPrimaryKey) { + // TODO: Avoid pk changes, or previous pk should be dropped before + } + $isUnique = in_array($column->name, $this->uniqueColumns, true); + $columnCode = ColumnToCode::wrapQuotesOnlyRaw($this->columnToCode($column, $isUnique, false)); + $tableName = $this->model->getTableAlias(); + $this->migration->addUpCode(sprintf(self::ADD_COLUMN, $tableName, $column->name, $columnCode)) + ->addDownCode(sprintf(self::DROP_COLUMN, $tableName, $column->name)); + if ($this->isPostgres && $column->dbType === 'enum') { + $items = ColumnToCode::enumToString($column->enumValues); + $this->migration->addUpCode(sprintf(self::ADD_ENUM, $column->name, $items), true) + ->addDownCode(sprintf(self::DROP_ENUM, $column->name)); + } + } + } + + /** + * @param array|\yii\db\ColumnSchema[] $columns + */ + private function buildColumnsDrop(array $columns):void + { + foreach ($columns as $column) { + if ($column->isPrimaryKey) { + // TODO: drop pk index and foreign keys before or avoid drop + } + $isUnique = in_array($column->name, $this->currentUniqueColumns, true); + $columnCode = $this->columnToCode($column, $isUnique, true); + $tableName = $this->model->getTableAlias(); + $this->migration->addDownCode(sprintf(self::ADD_COLUMN, $tableName, $column->name, $columnCode)) + ->addUpCode(sprintf(self::DROP_COLUMN, $tableName, $column->name)); + if ($this->isPostgres && $column->dbType === 'enum') { + $items = ColumnToCode::enumToString($column->enumValues); + $this->migration->addDownCode(sprintf(self::ADD_ENUM, $column->name, $items, true)) + ->addUpCode(sprintf(self::DROP_ENUM, $column->name), true); + } + } + } + + private function buildColumnChanges(ColumnSchema $current, ColumnSchema $desired):void + { + $isUniqueCurrent = in_array($current->name, $this->currentUniqueColumns, true); + $isUniqueDesired = in_array($desired->name, $this->uniqueColumns, true); + if ($current->isPrimaryKey || in_array($desired->dbType, ['pk', 'upk', 'bigpk', 'ubigpk'])) { + // do not adjust existing primary keys + return; + } + $columnName = $current->name; + $tableName = $this->model->getTableAlias(); + if ($isUniqueCurrent !== $isUniqueDesired) { + $addUnique = sprintf(self::ADD_UNIQUE, 'unique_' . $columnName, $tableName, $columnName); + $dropUnique = sprintf(self::DROP_INDEX, 'unique_' . $columnName, $tableName); + $this->migration->addUpCode($isUniqueDesired === true ? $addUnique : $dropUnique) + ->addDownCode($isUniqueDesired === true ? $dropUnique : $addUnique); + } + $changedAttributes = []; + foreach (['type', 'size', 'allowNull', 'defaultValue', 'enumValues'] as $attr) { + if ($current->$attr !== $desired->$attr) { + $changedAttributes[] = $attr; + } + } + if (empty($changedAttributes)) { + return; + } + if ($this->isPostgres) { + $this->buildColumnsChangePostgres($current, $desired, $changedAttributes); + return; + } + $newColumn = clone $current; + foreach ($changedAttributes as $attr) { + $newColumn->$attr = $desired->$attr; + } + if (!empty($newColumn->enumValues)) { + $newColumn->dbType = 'enum'; + } + $upCode = $this->columnToCode($newColumn, false, true); //unique marks solved in separated queries + $downCode = $this->columnToCode($current, false, true); + $upCode = ColumnToCode::wrapQuotesOnlyRaw($upCode); + $downCode = ColumnToCode::wrapQuotesOnlyRaw($downCode); + $this->migration->addUpCode(sprintf(self::ALTER_COLUMN, $tableName, $columnName, $upCode)) + ->addDownCode(sprintf(self::ALTER_COLUMN, $tableName, $columnName, $downCode)); + } + + private function buildColumnsChangePostgres(ColumnSchema $current, ColumnSchema $desired, array $changes):void + { + $tableName = $this->model->getTableAlias(); + $columnName = $desired->name; + $upCodes = $downCodes = []; + $isChangeToEnum = $current->type !== $desired->type && !empty($desired->enumValues); + $isChangeFromEnum = $current->type !== $desired->type && !empty($current->enumValues); + if ($isChangeToEnum) { + $items = ColumnToCode::enumToString($desired->enumValues); + $this->migration->addUpCode(sprintf(self::ADD_ENUM, $desired->name, $items), true); + } + if ($isChangeFromEnum) { + $this->migration->addUpCode(sprintf(self::DROP_ENUM, $current->name)); + } + if (!empty(array_intersect(['type', 'size'], $changes))) { + $upCodes[] = (new ColumnToCode($this->dbSchema, $desired, false))->resolveTypeOnly(); + $downCodes[] = (new ColumnToCode($this->dbSchema, $current, false, true))->resolveTypeOnly(); + } + if (in_array('allowNull', $changes, true)) { + $upCodes[] = $desired->allowNull === true ? 'DROP NOT NULL' : 'SET NOT NULL'; + $downCodes[] = $desired->allowNull === true ? 'SET NOT NULL' : 'DROP NOT NULL'; + } + if (in_array('defaultValue', $changes, true)) { + $upCodes[] = $desired->defaultValue !== null + ? 'SET ' . (new ColumnToCode($this->dbSchema, $desired, false))->resolveDefaultOnly() + : 'DROP DEFAULT'; + $downCodes[] = $current->defaultValue !== null + ? 'SET ' . (new ColumnToCode($this->dbSchema, $current, false))->resolveDefaultOnly() + : 'DROP DEFAULT'; + } + foreach ($upCodes as $upCode) { + $upCode = ColumnToCode::wrapQuotesOnlyRaw($upCode); + $this->migration->addUpCode(sprintf(self::ALTER_COLUMN, $tableName, $columnName, $upCode)); + } + foreach ($downCodes as $downCode) { + $downCode = ColumnToCode::wrapQuotesOnlyRaw($downCode); + $this->migration->addDownCode(sprintf(self::ALTER_COLUMN, $tableName, $columnName, $downCode), true); + } + if ($isChangeFromEnum) { + $items = ColumnToCode::enumToString($current->enumValues); + $this->migration->addDownCode(sprintf(self::ADD_ENUM, $current->name, $items), true); + } + if ($isChangeToEnum) { + $this->migration->addDownCode(sprintf(self::DROP_ENUM, $desired->name), true); + } + } + + private function buildRelations():void + { + $tableName = $this->model->getTableAlias(); + if (empty($this->model->relations)) { + //? Revert existed relations + foreach ($this->tableSchema->foreignKeys as $relation) { + $refTable = array_shift($relation); + $refCol = array_keys($relation)[0]; + $fkCol = $relation[$refCol]; + $fkName = $this->foreignKeyName($this->model->tableName, $fkCol, $refTable, $refCol); + $this->migration->addUpCode(sprintf(self::DROP_FK, $fkName, $tableName), true) + ->addDownCode(sprintf(self::ADD_FK, $fkName, $tableName, $fkCol, $refTable, $refCol)); + if ($refTable !== $this->model->tableName) { + $this->migration->dependencies[$refTable]; + } + } + } + foreach ($this->model->getHasOneRelations() as $relation) { + $fkCol = $relation->getColumnName(); + $refCol = $relation->getForeignName(); + $refTable = $relation->getTableAlias(); + $fkName = $this->foreignKeyName($this->model->tableName, $fkCol, $relation->getTableName(), $refCol); + if (isset($tableSchema->foreignKeys[$fkName])) { + continue; + } + $this->migration->addUpCode(sprintf(self::ADD_FK, $fkName, $tableName, $fkCol, $refTable, $refCol)) + ->addDownCode(sprintf(self::DROP_FK, $fkName, $tableName)); + if ($relation->getTableName() !== $this->model->tableName) { + $this->migration->dependencies[] = $refTable; + } + } + } + + private function findUniqueIndexes():array + { + try { + return $this->db->getSchema()->findUniqueIndexes($this->tableSchema); + } catch (NotSupportedException $e) { + return []; + } + } + + private function columnToCode(ColumnSchema $column, bool $isUnique, bool $fromDb = false):string + { + return (new ColumnToCode($this->dbSchema, $column, $isUnique, $fromDb))->resolve(); + } + + private function foreignKeyName(string $table, string $column, string $foreignTable, string $foreignColumn):string + { + $table = $this->normalizeTableName($table); + $foreignTable = $this->normalizeTableName($foreignTable); + return "fk_{$table}_{$column}_{$foreignTable}_{$foreignColumn}"; + } + + private function normalizeTableName($tableName) + { + if (preg_match('~^{{%?(.*)}}$~', $tableName, $m)) { + return $m[1]; + } + return $tableName; + } +} diff --git a/src/lib/MigrationsGenerator.php b/src/lib/MigrationsGenerator.php new file mode 100644 index 00000000..d08c67f3 --- /dev/null +++ b/src/lib/MigrationsGenerator.php @@ -0,0 +1,92 @@ + and contributors + * @license https://github.com/cebe/yii2-openapi/blob/master/LICENSE + */ + +namespace cebe\yii2openapi\lib; + +use cebe\yii2openapi\lib\items\MigrationModel; +use Exception; +use yii\base\Component; +use yii\db\Connection; +use yii\di\Instance; +use function ksort; + +class MigrationsGenerator extends Component +{ + /** + * @var string|array|Connection the Yii database connection component for connecting to the database. + */ + public $db = 'db'; + + /** + * @var MigrationModel[] + **/ + private $migrations; + /** + * @var MigrationModel[] + **/ + private $sorted; + + public function init() + { + parent::init(); + $this->db = Instance::ensure($this->db, Connection::class); + } + + /** + * @param array|\cebe\yii2openapi\lib\items\DbModel[] $models + * @return array|\cebe\yii2openapi\lib\items\MigrationModel[] + * @throws \Exception + */ + public function generate(array $models):array + { + foreach ($models as $model) { + $migration = (new MigrationBuilder($this->db, $model))->build(); + if ($migration->notEmpty()) { + $this->migrations[$model->tableAlias] = $migration; + } + } + return $this->sortMigrationsByDeps(); + } + + /** + * @return array|MigrationModel[] + * @throws \Exception + */ + private function sortMigrationsByDeps():array + { + $this->sorted = []; + ksort($this->migrations); + foreach ($this->migrations as $migration) { + //echo "adding {$migration->tableAlias}\n"; + $this->sortByDependencyRecurse($migration); + } + return $this->sorted; + } + + /** + * @param \cebe\yii2openapi\lib\items\MigrationModel $migration + * @throws \Exception + */ + private function sortByDependencyRecurse(MigrationModel $migration):void + { + if (!isset($this->sorted[$migration->tableAlias])) { + $this->sorted[$migration->tableAlias] = false; + foreach ($migration->dependencies as $dependency) { + if (!isset($this->migrations[$dependency])) { + //echo "skipping dep $dependency\n"; + continue; + } + //echo "adding dep $dependency\n"; + $this->sortByDependencyRecurse($this->migrations[$dependency]); + } + unset($this->sorted[$migration->tableAlias]);//necessary for provide valid order + $this->sorted[$migration->tableAlias] = $migration; + } elseif ($this->sorted[$migration->tableAlias] === false) { + throw new Exception("A circular dependency is detected for table '{$migration->tableAlias}'."); + } + } +} diff --git a/src/lib/SchemaToDatabase.php b/src/lib/SchemaToDatabase.php new file mode 100644 index 00000000..99c67d1e --- /dev/null +++ b/src/lib/SchemaToDatabase.php @@ -0,0 +1,119 @@ + and contributors + * @license https://github.com/cebe/yii2-openapi/blob/master/LICENSE + */ + +namespace cebe\yii2openapi\lib; + +use cebe\openapi\ReferenceContext; +use cebe\openapi\spec\OpenApi; +use cebe\openapi\spec\Reference; +use cebe\openapi\spec\Schema; +use Yii; +use yii\base\Component; + +/** + * Convert OpenAPI description into a database schema. + * There are two options: + * 1. let the generator guess which schemas need a database table + * for storing their data and which do not. + * 2. Explicitly define schemas which represent a database table by adding the + * `x-table` property to the schema. + * The [[]] + * + * OpenApi Schema definition rules for database conversion: + * https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md#schema-object + * + * components: + * schemas: + * ModelName: #(table name becomes model_names) + * description: #(optional, become as model class comment) + * required: #(list of required property names that can't be nullable) + * - id + * - some + * x-table: custom_table #(explicit database table name) + * x-pk: pid #(optional, primary key name if it called not "id") (composite keys not supported yet) + * properties: #(table columns and relations) + * prop_name: + * type: #(one of common types string|integer|number|boolean|array) + * format: #(see https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md#dataTypes) + * readOnly: true/false #(If true, should be skipped from validation rules) + * minimum: #(numeric value, applied for validation rules and faker generation) + * maximum: #(numeric value, applied for integer|number validation rules and faker generation) + * maxLength: #(numeric value, applied for database column size limit!, also can be applied for validation) + * minLength: #(numeric value, can be applied for validation rules) + * default: #(int|string, default value, used for database migration and model rules) + * x-db-type: #(Custom database type like JSON, JSONB, CHAR, VARCHAR, UUID, etc ) + * x-db-unique: true #(mark unique attribute for database and validation constraining) + * x-faker: #(custom faker generator, for ex '$faker->gender') + * description: #(optional, used for comment) + * + */ +class SchemaToDatabase extends Component +{ + + /** + * @var array List of model names to exclude. + */ + public $excludeModels = []; + + /** + * @var array Generate database models only for Schemas that have the `x-table` annotation. + */ + public $generateModelsOnlyXTable = false; + + public $attributeResolverClass = AttributeResolver::class; + + /** + * @param \cebe\openapi\spec\OpenApi $openApi + * @return array|\cebe\yii2openapi\lib\items\DbModel[] + * @throws \cebe\openapi\exceptions\UnresolvableReferenceException + * @throws \yii\base\InvalidConfigException + */ + public function generateModels(OpenApi $openApi):array + { + $models = []; + foreach ($openApi->components->schemas as $schemaName => $schema) { + if ($schema instanceof Reference) { + $schema->getContext()->mode = ReferenceContext::RESOLVE_MODE_INLINE; + $schema = $schema->resolve(); + } + + if (!$this->canGenerateModel($schemaName, $schema)) { + continue; + } + $resolver = Yii::createObject($this->attributeResolverClass, [$schemaName, $schema]); + $models[$schemaName] = $resolver->resolve(); + } + + // TODO generate inverse relations + + return $models; + } + + private function canGenerateModel(string $schemaName, Schema $schema):bool + { + // only generate tables for schemas of type object and those who have defined properties + if ((empty($schema->type) || $schema->type === 'object') && empty($schema->properties)) { + return false; + } + if (!empty($schema->type) && $schema->type !== 'object') { + return false; + } + // do not generate tables for composite schemas + if ($schema->allOf || $schema->anyOf || $schema->multipleOf || $schema->oneOf) { + return false; + } + // skip excluded model names + if (in_array($schemaName, $this->excludeModels, true)) { + return false; + } + + if ($this->generateModelsOnlyXTable && empty($schema->{CustomSpecAttr::TABLE})) { + return false; + } + return true; + } +} diff --git a/src/lib/TypeResolver.php b/src/lib/TypeResolver.php new file mode 100644 index 00000000..64de5fba --- /dev/null +++ b/src/lib/TypeResolver.php @@ -0,0 +1,102 @@ + and contributors + * @license https://github.com/cebe/yii2-openapi/blob/master/LICENSE + */ + +namespace cebe\yii2openapi\lib; + +use cebe\openapi\spec\Schema; +use yii\db\Schema as YiiDbSchema; +use yii\helpers\StringHelper; +use function in_array; +use function strtolower; + +class TypeResolver +{ + public static function schemaToPhpType(Schema $property):string + { + $customDbType = isset($property->{CustomSpecAttr::DB_TYPE}) + ? strtolower($property->{CustomSpecAttr::DB_TYPE}) : null; + if ($customDbType !== null + && (in_array($customDbType, ['json', 'jsonb'], true) || StringHelper::endsWith($customDbType, '[]')) + ) { + return 'array'; + } + switch ($property->type) { + case 'integer': + return 'int'; + case 'boolean': + return 'bool'; + case 'number': // can be double and float + return $property->format && $property->format === 'double' ? 'double' : 'float'; +// case 'array': +// return $property->type; + default: + return $property->type; + } + } + + public static function referenceToDbType(Schema $property):string + { + if ($property->type === 'integer') { + return $property->format === 'int64' ? YiiDbSchema::TYPE_BIGINT : YiiDbSchema::TYPE_INTEGER; + } + return $property->type; + } + + public static function schemaToDbType(Schema $property, bool $isPrimary = false):string + { + if (isset($property->{CustomSpecAttr::DB_TYPE})) { + $customDbType = strtolower($property->{CustomSpecAttr::DB_TYPE}); + if ($customDbType === 'varchar') { + return YiiDbSchema::TYPE_STRING; + } + if ($customDbType !== null) { + return $customDbType; + } + } + if ($isPrimary && $property->type === 'integer') { + return $property->format === 'int64' ? YiiDbSchema::TYPE_BIGPK : YiiDbSchema::TYPE_PK; + } + + switch ($property->type) { + case 'boolean': + return $property->type; + case 'number': // can be double and float + return $property->format ?? 'float'; + case 'integer': + if ($property->format === 'int64') { + return YiiDbSchema::TYPE_BIGINT; + } + if ($property->format === 'int32') { + return YiiDbSchema::TYPE_INTEGER; + } + return YiiDbSchema::TYPE_INTEGER; + case 'string': + if (in_array($property->format, ['date', 'time', 'binary'])) { + return $property->format; + } + if ($property->maxLength && $property->maxLength < 2049) { + //What if we want to restrict length of text column? + return YiiDbSchema::TYPE_STRING; + } + if ($property->format === 'date-time' || $property->format === 'datetime') { + return YiiDbSchema::TYPE_DATETIME; + } + if (in_array($property->format, ['email', 'url', 'phone', 'password'])) { + return YiiDbSchema::TYPE_STRING; + } + if (isset($property->enum) && !empty($property->enum)) { + return YiiDbSchema::TYPE_STRING; + } + return YiiDbSchema::TYPE_TEXT; +// case 'array': +// Need schema example for this case if it possible +// return $this->typeForArray(); + default: + return YiiDbSchema::TYPE_TEXT; + } + } +} diff --git a/src/lib/ValidationRulesBuilder.php b/src/lib/ValidationRulesBuilder.php new file mode 100644 index 00000000..2c6ef038 --- /dev/null +++ b/src/lib/ValidationRulesBuilder.php @@ -0,0 +1,107 @@ + and contributors + * @license https://github.com/cebe/yii2-openapi/blob/master/LICENSE + */ + +namespace cebe\yii2openapi\lib; + +use cebe\yii2openapi\lib\items\DbModel; +use cebe\yii2openapi\lib\items\ValidationRule; + +class ValidationRulesBuilder +{ + /** + * @var \cebe\yii2openapi\lib\items\DbModel + */ + private $model; + + /** + * @var array|ValidationRule[] + **/ + private $rules = []; + + private $typeScope = [ + 'safe' => [], 'required' => [], 'int' => [], 'bool' => [], 'float' => [], 'string' => [], 'ref' => [] + ]; + + public function __construct(DbModel $model) + { + $this->model = $model; + } + + /** + * @return array|\cebe\yii2openapi\lib\items\ValidationRule[] + */ + public function build(): array + { + $this->prepareTypeScope(); + $this->rulesByType(); + return $this->rules; + } + + private function prepareTypeScope():void + { + foreach ($this->model->attributes as $attribute) { + if ($attribute->isReadOnly()) { + continue; + } + if ($attribute->isRequired()) { + $this->typeScope['required'][$attribute->columnName] = $attribute->columnName; + } + + if ($attribute->isReference()) { + if (in_array($attribute->phpType, ['int', 'string'])) { + $this->typeScope[$attribute->phpType][$attribute->columnName] = $attribute->columnName; + } + $this->typeScope['ref'][] = ['attr' => $attribute->columnName, 'rel' => $attribute->camelName()]; + continue; + } + + if (in_array($attribute->phpType, ['int', 'string', 'bool', 'float'])) { + $this->typeScope[$attribute->phpType][$attribute->columnName] = $attribute->columnName; + continue; + } + + if ($attribute->phpType === 'double') { + $this->typeScope['float'][$attribute->columnName] = $attribute->columnName; + continue; + } + + $this->typeScope['safe'][$attribute->columnName] = $attribute->columnName; + } + } + + private function rulesByType():void + { + if (!empty($this->typeScope['string'])) { + $this->rules[] = new ValidationRule($this->typeScope['string'], 'trim'); + } + if (!empty($this->typeScope['required'])) { + $this->rules[] = new ValidationRule($this->typeScope['required'], 'required'); + } + + if (!empty($this->typeScope['int'])) { + $this->rules[] = new ValidationRule($this->typeScope['int'], 'integer'); + } + + foreach ($this->typeScope['ref'] as $relation) { + $this->rules[] = new ValidationRule([$relation['attr']], 'exist', ['targetRelation'=>$relation['rel']]); + } + + if (!empty($this->typeScope['string'])) { + $this->rules[] = new ValidationRule($this->typeScope['string'], 'string'); + } + + if (!empty($this->typeScope['float'])) { + $this->rules[] = new ValidationRule($this->typeScope['float'], 'double'); + } + if (!empty($this->typeScope['bool'])) { + $this->rules[] = new ValidationRule($this->typeScope['bool'], 'boolean'); + } + if (!empty($this->typeScope['safe'])) { + $this->rules[] = new ValidationRule($this->typeScope['safe'], 'safe'); + } + } +} diff --git a/src/lib/items/Attribute.php b/src/lib/items/Attribute.php new file mode 100644 index 00000000..6d50b14e --- /dev/null +++ b/src/lib/items/Attribute.php @@ -0,0 +1,268 @@ + and contributors + * @license https://github.com/cebe/yii2-openapi/blob/master/LICENSE + */ + +namespace cebe\yii2openapi\lib\items; + +use yii\base\BaseObject; +use yii\db\ColumnSchema; +use yii\helpers\Inflector; +use function is_array; +use function strtolower; + +/** + * @property-write mixed $default + * @property-write bool $isPrimary + * @property-read string $formattedDescription + */ +class Attribute extends BaseObject +{ + /** + * openApi schema property name + * @var string + */ + public $propertyName; + + /** + * should be string/integer/boolean/float/double + * @var string + */ + public $phpType = 'string'; + + /** + * model/database column name + * @var string + */ + public $columnName; + + /** + * should be one of \yii\db\Schema types or complete db column definition + * @var string + */ + public $dbType = 'string'; + + /** + * @var string + */ + public $description = ''; + + /** + * @var bool + */ + public $readOnly = false; + + /** + * @var bool + */ + public $required = false; + + /** + * related object name, if it exists + * @var string + */ + public $reference; + /** + * @var int|null (db field length) + **/ + public $size; + + public $limits = ['min' => null, 'max' => null, 'minLength' => null]; + + /** + * @var bool + */ + public $unique = false; + + /** + * @var bool + */ + public $primary = false; + + /** + * @var mixed + */ + public $defaultValue; + + /** + * @var array|null + */ + public $enumValues; + + /** + * @var string|null + **/ + public $fakerStub; + + public function __construct(string $propertyName, array $config = []) + { + $this->propertyName = $propertyName; + $this->columnName = $propertyName; // force camel2id ? + parent::__construct($config); + } + + public function setPhpType(string $phpType):Attribute + { + $this->phpType = $phpType; + return $this; + } + + public function setDbType(string $dbType):Attribute + { + $this->dbType = $dbType; + return $this; + } + + public function setDescription(string $description):Attribute + { + $this->description = $description; + return $this; + } + + public function setReadOnly(bool $readOnly = true):Attribute + { + $this->readOnly = $readOnly; + return $this; + } + + public function setRequired(bool $required = true):Attribute + { + $this->required = $required; + return $this; + } + + public function setUnique(bool $unique = true):Attribute + { + $this->unique = $unique; + return $this; + } + + public function setSize(?int $size):Attribute + { + $this->size = $size; + return $this; + } + + public function setDefault($value):Attribute + { + $this->defaultValue = $value; + return $this; + } + + public function setEnumValues(array $values):Attribute + { + $this->enumValues = $values; + return $this; + } + + /** + * @param int|float|null $min + * @param int|float|null $max + * @param int|null $minLength + * @return $this + */ + public function setLimits($min, $max, ?int $minLength):Attribute + { + $this->limits = ['min' => $min, 'max' => $max, 'minLength' => $minLength]; + return $this; + } + + public function setFakerStub(?string $fakerStub):Attribute + { + $this->fakerStub = $fakerStub; + return $this; + } + + public function setIsPrimary(bool $isPrimary = true):Attribute + { + $this->primary = $isPrimary; + return $this; + } + + + public function asReference(string $relatedClass):Attribute + { + $this->reference = $relatedClass; + $this->columnName = $this->propertyName . '_id'; + return $this; + } + public function isReadOnly():bool + { + return $this->readOnly; + } + + public function isReference():bool + { + return $this->reference !== null; + } + + public function isUnique():bool + { + return $this->unique; + } + + public function isRequired():bool + { + return $this->required; + } + + public function camelName():string + { + return Inflector::camelize($this->propertyName); + } + + public function getFormattedDescription():string + { + $comment = $this->columnName.' '.$this->description; + $type = $this->phpType; + return $type.' $'.str_replace("\n", "\n * ", rtrim($comment)); + } + + public function toColumnSchema():ColumnSchema + { + $column = new ColumnSchema([ + 'name' => $this->columnName, + 'phpType'=>$this->phpType, + 'dbType' => strtolower($this->dbType), + 'type' => $this->dbTypeAbstract($this->dbType), + 'allowNull' => !$this->isRequired(), + 'size' => $this->size > 0 ? $this->size : null, + ]); + if ($column->type === 'json') { + $column->allowNull = false; + } + if ($this->defaultValue !== null) { + $column->defaultValue = $this->defaultValue; + } elseif ($column->allowNull) { + //@TODO: Need to discuss + $column->defaultValue = null; + } + if (is_array($this->enumValues)) { + $column->enumValues = $this->enumValues; + } + + return $column; + } + + private function dbTypeAbstract(string $type):string + { + if (stripos($type, 'int') === 0) { + return 'integer'; + } + if (stripos($type, 'string') === 0) { + return 'string'; + } + if (stripos($type, 'varchar') === 0) { + return 'string'; + } + if (stripos($type, 'json') === 0) { + return 'json'; + } + if (stripos($type, 'datetime') === 0) { + return 'timestamp'; + } + return $type; + } +} diff --git a/src/lib/items/AttributeRelation.php b/src/lib/items/AttributeRelation.php new file mode 100644 index 00000000..83c5a9b3 --- /dev/null +++ b/src/lib/items/AttributeRelation.php @@ -0,0 +1,178 @@ + and contributors + * @license https://github.com/cebe/yii2-openapi/blob/master/LICENSE + */ + +namespace cebe\yii2openapi\lib\items; + +use yii\helpers\Inflector; +use function reset; + +class AttributeRelation +{ + public const HAS_ONE = 'hasOne'; + public const HAS_MANY = 'hasMany'; + + /** + * @var string $name + **/ + private $name; + + /** + * @var string $tableName + **/ + private $tableName; + + /** + * @var string $className + **/ + private $className; + + /** + * @var string $method (hasOne/hasMany) + **/ + private $method; + + /** + * @var array + **/ + private $link = []; + + /**@var bool */ + private $selfReference = false; + + public function __construct( + string $name, + ?string $tableName = null, + ?string $className = null, + ?string $method = null, + array $link = [] + ) { + $this->name = $name; + $this->tableName = $tableName; + $this->className = $className; + $this->method = $method; + $this->link = $link; + } + + /** + * @param string $name + * @return AttributeRelation + */ + public function setName(string $name):AttributeRelation + { + $this->name = $name; + return $this; + } + + /** + * @param string $tableName + * @return AttributeRelation + */ + public function setTableName(string $tableName):AttributeRelation + { + $this->tableName = $tableName; + return $this; + } + + /** + * @param string $className + * @return AttributeRelation + */ + public function setClassName(string $className):AttributeRelation + { + $this->className = $className; + return $this; + } + + public function asSelfReference():AttributeRelation + { + $this->selfReference = true; + return $this; + } + + public function asHasOne(array $link):AttributeRelation + { + $this->method = self::HAS_ONE; + $this->link = $link; + return $this; + } + + public function asHasMany(array $link):AttributeRelation + { + $this->method = self::HAS_MANY; + $this->link = $link; + return $this; + } + + public function isHasOne():bool + { + return $this->method === self::HAS_ONE; + } + + public function isSelfReferenced():bool + { + return $this->selfReference; + } + /** + * @return string + */ + public function getName():string + { + return $this->name; + } + + /** + * @return string + */ + public function getTableName():string + { + return $this->tableName; + } + + public function getTableAlias():string + { + return "{{%$this->tableName}}"; + } + + /** + * @return string + */ + public function getClassName():string + { + return $this->className; + } + + /** + * @return string + */ + public function getMethod():string + { + return $this->method; + } + + /** + * @return array + */ + public function getLink():array + { + return $this->link; + } + + public function getCamelName():string + { + return Inflector::camelize($this->name); + } + + public function getColumnName():string + { + return reset($this->link); + } + + public function getForeignName():string + { + return key($this->link); + } +} diff --git a/src/lib/items/DbModel.php b/src/lib/items/DbModel.php new file mode 100644 index 00000000..d2d844e4 --- /dev/null +++ b/src/lib/items/DbModel.php @@ -0,0 +1,110 @@ + and contributors + * @license https://github.com/cebe/yii2-openapi/blob/master/LICENSE + */ + +namespace cebe\yii2openapi\lib\items; + +use cebe\yii2openapi\lib\ValidationRulesBuilder; +use yii\base\BaseObject; +use yii\db\ColumnSchema; +use yii\helpers\StringHelper; +use function array_filter; + +/** + * @property-read string $tableAlias + * @property-read array $uniqueColumnsList + * @property-read array[]|array $attributesByType + * @property-read array|\cebe\yii2openapi\lib\items\AttributeRelation[] $hasOneRelations + */ +class DbModel extends BaseObject +{ + /** + * @var string model name. + */ + public $name; + + /** + * @var string table name. (without brackets and db prefix) + */ + public $tableName; + + /** + * @var string description from the schema. + */ + public $description; + + /** + * @var array|\cebe\yii2openapi\lib\items\Attribute[] model attributes. + */ + public $attributes = []; + + /** + * @var array|\cebe\yii2openapi\lib\items\AttributeRelation[] database relations. + */ + public $relations = []; + + public function getTableAlias():string + { + return '{{%' . $this->tableName . '}}'; + } + + public function getValidationRules():array + { + return (new ValidationRulesBuilder($this))->build(); + } + + public function getUniqueColumnsList():array + { + $uniques = []; + foreach ($this->attributes as $attribute) { + if ($attribute->isUnique()) { + $uniques[] = $attribute->columnName; + } + } + return $uniques; + } + + /** + * @return \cebe\yii2openapi\lib\items\AttributeRelation[]|array + */ + public function getHasOneRelations():array + { + return array_filter( + $this->relations, + static function (AttributeRelation $relation) { + return $relation->isHasOne(); + } + ); + } + + /** + * @return ColumnSchema[] + */ + public function attributesToColumnSchema():array + { + return array_reduce( + $this->attributes, + static function ($acc, Attribute $attribute) { + $acc[$attribute->columnName] = $attribute->toColumnSchema(); + return $acc; + }, + [] + ); + } + + /** + * @return array|\cebe\yii2openapi\lib\items\Attribute[] + */ + public function getEnumAttributes():array + { + return array_filter( + $this->attributes, + function (Attribute $attribute) { + return StringHelper::startsWith($attribute->dbType, 'enum') && !empty($attribute->enumValues); + } + ); + } +} diff --git a/src/lib/items/MigrationModel.php b/src/lib/items/MigrationModel.php new file mode 100644 index 00000000..d981c2a3 --- /dev/null +++ b/src/lib/items/MigrationModel.php @@ -0,0 +1,138 @@ + and contributors + * @license https://github.com/cebe/yii2-openapi/blob/master/LICENSE + */ + +namespace cebe\yii2openapi\lib\items; + +use yii\base\BaseObject; +use yii\helpers\Inflector; +use function array_push; +use function array_unshift; +use function implode; +use function is_string; +use const PHP_EOL; + +/** + * @property-read string $tableAlias + * @property-read string $upCodeString + * @property-read string $downCodeString + * @property-read string $fileClassName + */ +class MigrationModel extends BaseObject +{ + /** + * @var string + **/ + public $fileName; + + /** + * @var array + **/ + public $upCodes = []; + + /** + * @var array + **/ + public $downCodes = []; + + /** + * @var array + **/ + public $dependencies = []; + + /** + * @var string + **/ + private $fileClassName = ''; + + /** + * @var \cebe\yii2openapi\lib\items\DbModel + */ + private $model; + + public function __construct(DbModel $model, bool $isFresh = true, $config = []) + { + parent::__construct($config); + $this->model = $model; + $this->fileName = $isFresh + ? 'create_table_' . $model->tableName + : 'change_table_' . $model->tableName; + } + + public function getUpCodeString():string + { + return !empty($this->upCodes) ? implode(PHP_EOL, $this->upCodes) : ''; + } + + public function getDownCodeString():string + { + return !empty($this->downCodes) ? implode(PHP_EOL, $this->downCodes) : ''; + } + + public function notEmpty():bool + { + return !empty($this->upCodes) && !empty($this->downCodes); + } + + public function getTableAlias():string + { + return $this->model->tableAlias; + } + + public function getFileClassName():string + { + return $this->fileClassName; + } + + public function getDescription():string + { + return 'Table for '.$this->model->name; + } + + public function makeClassNameByTime(int $index, ?string $nameSpace = null, ?string $date = null):string + { + if ($nameSpace) { + $m = sprintf('%s%04d', ($date ?: date('ymdH')), $index); + $this->fileClassName = "M{$m}" . Inflector::id2camel($this->fileName, '_'); + } else { + $m = sprintf('%s%04d', ($date ?: date('ymd_H')), $index); + $this->fileClassName = "m{$m}_" . $this->fileName; + } + return $this->fileClassName; + } + + /**Add up code, by default at bottom + * @param array|string $code + * @param bool $toTop + * @return $this + */ + public function addUpCode($code, bool $toTop = false):MigrationModel + { + $code = is_string($code) ? [$code] : $code; + if ($toTop === true) { + array_unshift($this->upCodes, ...$code); + } else { + array_push($this->upCodes, ...$code); + } + return $this; + } + + /**add down code, by default to top + * @param array|string $code + * @param bool $toBottom + * @return $this + */ + public function addDownCode($code, bool $toBottom = false):MigrationModel + { + $code = is_string($code) ? [$code] : $code; + if ($toBottom === true) { + array_push($this->downCodes, ...$code); + } else { + array_unshift($this->downCodes, ...$code); + } + return $this; + } +} diff --git a/src/lib/items/ValidationRule.php b/src/lib/items/ValidationRule.php new file mode 100644 index 00000000..f9e2d9af --- /dev/null +++ b/src/lib/items/ValidationRule.php @@ -0,0 +1,97 @@ + and contributors + * @license https://github.com/cebe/yii2-openapi/blob/master/LICENSE + */ + +namespace cebe\yii2openapi\lib\items; + +use yii\base\BaseObject; +use yii\helpers\ArrayHelper; +use yii\helpers\VarDumper; +use function gettype; +use function implode; +use function is_string; +use function sprintf; + +class ValidationRule extends BaseObject +{ + /**@var array * */ + public $attributes = []; + + /**@var string * */ + public $validator; + + /**@var array * */ + public $params = []; + + public function __construct(array $attributes, string $validator, array $params = [], $config = []) + { + $this->attributes = array_values($attributes); + $this->validator = $validator; + $this->params = $params; + parent::__construct($config); + } + + /** + * @param string $key + * @param int|string|array $value + * @return $this + */ + public function addParam(string $key, $value):ValidationRule + { + $this->params[$key] = $value; + return $this; + } + + public function withParams(array $params):ValidationRule + { + $this->params = $params; + return $this; + } + + public function __toString():string + { + $attrs = implode("', '", $this->attributes); + $params = empty($this->params) ? '' : ', ' . $this->arrayToString($this->params); + return sprintf(" [['%s'], '%s'%s]", $attrs, $this->validator, $params); + } + + private function arrayToString(array $data):string + { + $params = []; + foreach ($data as $key => $val) { + $type = gettype($val); + switch ($type) { + case 'NULL': + case 'boolean': + $value = VarDumper::export($val); + break; + case 'integer': + case 'float': + case 'double': + $value = $val; + break; + case 'array': + if (empty($val)) { + $value = '[]'; + } elseif (ArrayHelper::isIndexed($val)) { + $value = "['" . implode("', '", $val) . "']"; + } else { + $value = '[' . $this->arrayToString($val) . ']'; + } + break; + case 'resource': + case 'object': + //probably will be resolved later + $value = "''"; + break; + default: + $value = "'$val'"; + } + $params[] = is_string($key) ? "'$key' => $value" : $value; + } + return implode(', ', $params); + } +} diff --git a/tests/DbTestCase.php b/tests/DbTestCase.php new file mode 100644 index 00000000..4d6ec518 --- /dev/null +++ b/tests/DbTestCase.php @@ -0,0 +1,34 @@ + 'yii2-openapi-test', + 'basePath' => __DIR__ . '/tmp/app', + 'components'=>[] + ], $extendConfig); + if($dbMock !== null){ + $config['components']['db'] = $dbMock; + } + return new Application($config); + } + + protected function mockRealApplication($config = [], $appClass = '\yii\console\Application') + { + $fileConfig = require __DIR__ . '/config/console.php'; + new $appClass(ArrayHelper::merge($fileConfig, $config)); + } + + protected function mockDbSchemaAsEmpty($driver = 'mysql') + { + $schema = $this->createMock(Schema::class); + $schema->method('getTableSchema')->willReturn(null); + $schema->method('findUniqueIndexes')->willReturn([]); + $schema->method('quoteValue')->willReturnCallback(function($v){ return "'$v'";}); + $db = $this->createMock(Connection::class); + $db->method('getSchema')->willReturn($schema); + $db->method('getTableSchema')->willReturn(null); + $db->method('getDriverName')->willReturn($driver); + return $db; + } +} \ No newline at end of file diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 99935ed2..4ba19919 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -1,6 +1,9 @@ 'cebe/yii2-openapi', + 'timeZone' => 'UTC', + 'basePath' => dirname(__DIR__) . '/tmp/docker_app', + 'runtimePath' => dirname(__DIR__) . '/tmp', + 'vendorPath' => dirname(__DIR__, 2) . '/vendor', + 'aliases' => [ + '@bower' => '@vendor/bower-asset', + '@npm' => '@vendor/npm-asset', + ], + //'bootstrap'=>['log'], + 'controllerMap' => [ + 'migrate' => [ + 'class' => \yii\console\controllers\MigrateController::class, + 'migrationPath' => dirname(__DIR__).'/migrations', + ], + ], + 'components' => [ + 'pgsql' => [ + 'class' => \yii\db\Connection::class, + 'dsn' => 'pgsql:host=postgres;dbname=testdb', + 'username' => 'dbuser', + 'password' => 'dbpass', + 'charset' => 'utf8', + 'tablePrefix'=>'itt_', + ], + 'mysql' => [ + 'class' => \yii\db\Connection::class, + 'dsn' => 'mysql:host=mysql;dbname=testdb', + 'username' => 'dbuser', + 'password' => 'dbpass', + 'charset' => 'utf8', + 'tablePrefix'=>'itt_', + ], + 'maria' => [ + 'class' => \yii\db\Connection::class, + 'dsn' => 'mysql:host=maria;dbname=testdb', + 'username' => 'dbuser', + 'password' => 'dbpass', + 'charset' => 'utf8', + 'tablePrefix'=>'itt_', + ], + 'db'=>[ + 'class' => \yii\db\Connection::class, + 'dsn' => 'mysql:host=mysql;dbname=testdb', + 'username' => 'dbuser', + 'password' => 'dbpass', + 'charset' => 'utf8', + 'tablePrefix'=>'itt_', + ], + ], +]; diff --git a/tests/docker/Dockerfile b/tests/docker/Dockerfile new file mode 100644 index 00000000..52955801 --- /dev/null +++ b/tests/docker/Dockerfile @@ -0,0 +1,59 @@ +FROM php:7.1-cli + +ENV DEBIAN_FRONTEND=noninteractive + +RUN echo "force-unsafe-io" > /etc/dpkg/dpkg.cfg.d/02apt-speedup && \ + echo "Acquire::http {No-Cache=True;};" > /etc/apt/apt.conf.d/no-cache +RUN apt-get update && \ + apt-get -y install \ + gnupg2 && \ + apt-key update && \ + apt-get update && \ + apt-get install -y --no-install-recommends \ + imagemagick \ + libmagickwand-dev libmagickcore-dev \ + libfreetype6-dev \ + libjpeg62-turbo-dev \ + libpng-dev \ + libwebp-dev \ + libicu-dev \ + libzip-dev \ + libpq-dev \ + nano \ + git \ + unzip\ + libxml2-dev \ + curl \ + libcurl4-openssl-dev \ + libssl-dev \ + --no-install-recommends && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \ + && docker-php-ext-install \ + zip \ + curl \ + bcmath \ + exif \ + gd \ + iconv \ + intl \ + opcache \ + pdo_mysql \ + pdo_pgsql \ + mbstring + +# Install composer +ENV COMPOSER_ALLOW_SUPERUSER=1 \ + PHP_USER_ID=33 \ + PHP_ENABLE_XDEBUG=0 \ + COMPOSER_HOME=/root/.composer/ \ + PATH=/app:/app/vendor/bin:/root/.composer/vendor/bin:$PATH + +RUN curl -o /tmp/composer-setup.php https://getcomposer.org/installer \ +&& curl -o /tmp/composer-setup.sig https://composer.github.io/installer.sig \ +# Make sure we're installing what we think we're installing! +&& php -r "if (hash('SHA384', file_get_contents('/tmp/composer-setup.php')) !== trim(file_get_contents('/tmp/composer-setup.sig'))) { unlink('/tmp/composer-setup.php'); echo 'Invalid installer' . PHP_EOL; exit(1); }" \ +&& php /tmp/composer-setup.php --no-ansi --install-dir=/usr/local/bin --filename=composer \ +&& rm -f /tmp/composer-setup.* + +WORKDIR /app diff --git a/tests/fixtures/blog.php b/tests/fixtures/blog.php new file mode 100644 index 00000000..3de13344 --- /dev/null +++ b/tests/fixtures/blog.php @@ -0,0 +1,111 @@ + new DbModel([ + 'name' => 'User', + 'tableName' => 'users', + 'description' => 'The User', + 'attributes' => [ + 'id' => (new Attribute('id', ['phpType' => 'int', 'dbType' => 'pk'])) + ->setReadOnly()->setRequired()->setIsPrimary()->setFakerStub('$uniqueFaker->numberBetween(0, 2147483647)'), + 'username' => (new Attribute('username', ['phpType' => 'string', 'dbType' => 'string'])) + ->setSize(200)->setRequired()->setUnique()->setFakerStub('substr($faker->userName, 0, 200)'), + 'email' => (new Attribute('email', ['phpType' => 'string', 'dbType' => 'string'])) + ->setSize(200)->setUnique()->setRequired()->setFakerStub('substr($faker->safeEmail, 0, 200)'), + 'password' => (new Attribute('password', ['phpType' => 'string', 'dbType' => 'string'])) + ->setRequired()->setFakerStub('$faker->password'), + 'role' => (new Attribute('role', ['phpType' => 'string', 'dbType' => 'string'])) + ->setSize(20) + ->setDefault('reader') + ->setFakerStub('$faker->randomElement([\'admin\', \'editor\', \'reader\'])'), + 'created_at' => (new Attribute('created_at', ['phpType' => 'string', 'dbType' => 'datetime'])) + ->setDefault('CURRENT_TIMESTAMP')->setFakerStub('$faker->dateTimeThisCentury->format(\'Y-m-d H:i:s\')'), + ], + 'relations' => [], + ]), + 'category' => new DbModel([ + 'name' => 'Category', + 'tableName' => 'categories', + 'description' => 'Category of posts', + 'attributes' => [ + 'id' => (new Attribute('id', ['phpType' => 'int', 'dbType' => 'pk'])) + ->setReadOnly()->setRequired()->setIsPrimary()->setFakerStub('$uniqueFaker->numberBetween(0, 2147483647)'), + 'title' => (new Attribute('title', ['phpType' => 'string', 'dbType' => 'string'])) + ->setRequired()->setUnique()->setSize(255)->setFakerStub('substr($faker->sentence, 0, 255)'), + 'active' => (new Attribute('active', ['phpType' => 'bool', 'dbType' => 'boolean'])) + ->setRequired()->setDefault(false)->setFakerStub('$faker->boolean'), + ], + 'relations' => [ + 'posts' => new AttributeRelation('posts', 'blog_posts', 'Post', 'hasMany', ['category_id' => 'id']), + ], + ]), + 'post' => new DbModel([ + 'name' => 'Post', + 'tableName' => 'blog_posts', + 'description' => 'A blog post (uid used as pk for test purposes)', + 'attributes' => [ + 'uid' => (new Attribute('uid', ['phpType' => 'int', 'dbType' => 'bigpk'])) + ->setReadOnly()->setRequired()->setIsPrimary()->setFakerStub('$uniqueFaker->numberBetween(0, 2147483647)'), + 'title' => (new Attribute('title', ['phpType' => 'string', 'dbType' => 'string'])) + ->setRequired()->setUnique()->setSize(255)->setFakerStub('substr($faker->sentence, 0, 255)'), + 'slug' => (new Attribute('slug', ['phpType' => 'string', 'dbType' => 'string'])) + ->setUnique()->setSize(200)->setLimits(null, null, 1)->setFakerStub('substr($uniqueFaker->slug, 0, 200)'), + 'active' => (new Attribute('active', ['phpType' => 'bool', 'dbType' => 'boolean'])) + ->setRequired()->setDefault(false)->setFakerStub('$faker->boolean'), + 'category' => (new Attribute('category', ['phpType' => 'int', 'dbType' => 'integer'])) + ->asReference('Category') + ->setRequired() + ->setDescription('Category of posts') + ->setFakerStub('$uniqueFaker->numberBetween(0, 2147483647)'), + 'created_at' => (new Attribute('created_at', ['phpType' => 'string', 'dbType' => 'date'])) + ->setFakerStub('$faker->iso8601'), + 'created_by' => (new Attribute('created_by', ['phpType' => 'int', 'dbType' => 'integer'])) + ->asReference('User') + ->setDescription('The User') + ->setFakerStub('$uniqueFaker->numberBetween(0, 2147483647)'), + ], + 'relations' => [ + 'category' => new AttributeRelation('category', + 'categories', + 'Category', + 'hasOne', + ['id' => 'category_id']), + 'created_by' => new AttributeRelation('created_by', 'users', 'User', 'hasOne', ['id' => 'created_by_id']), + 'comments' => new AttributeRelation('comments', 'post_comments', 'Comment', 'hasMany', ['post_id' => 'uid']), + ], + ]), + 'comment' => new DbModel([ + 'name' => 'Comment', + 'tableName' => 'post_comments', + 'description' => '', + 'attributes' => [ + 'id' => (new Attribute('id', ['phpType' => 'int', 'dbType' => 'bigpk'])) + ->setReadOnly(true) + ->setRequired(true) + ->setIsPrimary(true) + ->setFakerStub('$uniqueFaker->numberBetween(0, 2147483647)'), + 'post' => (new Attribute('post', ['phpType' => 'int', 'dbType' => 'bigint'])) + ->setRequired() + ->asReference('Post') + ->setDescription('A blog post (uid used as pk for test purposes)') + ->setFakerStub('$uniqueFaker->numberBetween(0, 2147483647)'), + 'author' => (new Attribute('author', ['phpType' => 'int', 'dbType' => 'integer'])) + ->setRequired() + ->asReference('User') + ->setDescription('The User') + ->setFakerStub('$uniqueFaker->numberBetween(0, 2147483647)'), + 'message' => (new Attribute('message', ['phpType' => 'array', 'dbType' => 'json'])) + ->setRequired()->setDefault('{}')->setFakerStub('[]'), + 'created_at' => (new Attribute('created_at',['phpType' => 'int', 'dbType' => 'integer'])) + ->setRequired()->setFakerStub('$faker->unixTime'), + ], + 'relations' => [ + 'post' => new AttributeRelation('post', 'blog_posts', 'Post', 'hasOne', ['uid' => 'post_id']), + 'author' => new AttributeRelation('author', 'users', 'User', 'hasOne', ['id' => 'author_id']), + ], + ]), +]; \ No newline at end of file diff --git a/tests/migrations/m100000_000000_maria.php b/tests/migrations/m100000_000000_maria.php new file mode 100644 index 00000000..1f0f890d --- /dev/null +++ b/tests/migrations/m100000_000000_maria.php @@ -0,0 +1,113 @@ +db = 'maria'; + parent::init(); + } + + public function up() + { + $this->dropTableIfExists('{{%v2_fakerable}}'); + $this->dropTableIfExists('{{%v2_comments}}'); + $this->dropTableIfExists('{{%v2_posts}}'); + $this->dropTableIfExists('{{%v2_users}}'); + $this->dropTableIfExists('{{%v2_categories}}'); + + $this->createTable('{{%v2_categories}}', + [ + 'id' => $this->primaryKey(), + 'title' => $this->string(255)->notNull()->unique(), + 'active' => $this->boolean()->notNull()->defaultValue(false), + ]); + $this->createTable('{{%v2_users}}', + [ + 'id' => $this->primaryKey(), + 'username' => $this->string(200)->notNull()->unique(), + 'email' => $this->string(200)->notNull()->unique(), + 'password' => $this->string()->notNull(), + 'role' => $this->string(20)->null()->defaultValue('reader'), + 'created_at' => $this->timestamp()->null()->defaultExpression("CURRENT_TIMESTAMP"), + ]); + $this->createTable('{{%v2_posts}}', + [ + 'uid' => $this->bigPrimaryKey(), + 'title' => $this->string(255)->notNull()->unique(), + 'slug' => $this->string(200)->null()->defaultValue(null)->unique(), + 'category_id' => $this->integer()->notNull(), + 'active' => $this->boolean()->notNull()->defaultValue(false), + 'created_at' => $this->date()->null()->defaultValue(null), + 'created_by_id' => $this->integer()->null()->defaultValue(null), + ]); + $this->addForeignKey('fk_v2_posts_category_id_categories_id', + '{{%v2_posts}}', + 'category_id', + '{{%v2_categories}}', + 'id'); + $this->addForeignKey('fk_v2_posts_created_by_id_users_id', + '{{%v2_posts}}', + 'created_by_id', + '{{%v2_users}}', + 'id'); + $this->createTable('{{%v2_comments}}', + [ + 'id' => $this->bigPrimaryKey(), + 'post_id' => $this->bigInteger()->notNull(), + 'author_id' => $this->integer()->notNull(), + 'message' => $this->json()->notNull()->defaultValue('{}'), + 'created_at' => $this->integer()->notNull(), + ]); + $this->addForeignKey('fk_v2_comments_post_id_v2_posts_uid', + '{{%v2_comments}}', + 'post_id', + '{{%v2_posts}}', + 'uid'); + $this->addForeignKey('fk_v2_comments_author_id_v2_users_id', + '{{%v2_comments}}', + 'author_id', + '{{%v2_users}}', + 'id'); + $this->createTable('{{%v2_fakerable}}', [ + 'id' => $this->bigPrimaryKey(), + 'active' => $this->boolean()->null()->defaultValue(null), + 'floatval' => $this->float()->null()->defaultValue(null), + 'floatval_lim' => $this->float()->null()->defaultValue(null), + 'doubleval' => $this->double()->null()->defaultValue(null), + 'int_min' => $this->integer()->null()->defaultValue(3), + 'int_max' => $this->integer()->null()->defaultValue(null), + 'int_minmax' => $this->integer()->null()->defaultValue(null), + 'int_created_at' => $this->integer()->null()->defaultValue(null), + 'int_simple' => $this->integer()->null()->defaultValue(null), + 'str_text' => $this->text()->null()->defaultValue(null), + 'str_varchar' => $this->string(100)->null()->defaultValue(null), + 'str_date' => $this->date()->null()->defaultValue(null), + 'str_datetime' => $this->timestamp()->null()->defaultValue(null), + 'str_country' => $this->text()->null()->defaultValue(null), + ]); + } + + public function down() + { + $this->dropTable('{{%v2_fakerable}}'); + $this->dropForeignKey('fk_v2_comments_author_id_v2_users_id', '{{%v2_comments}}'); + $this->dropForeignKey('fk_v2_comments_post_id_v2_posts_uid', '{{%v2_comments}}'); + $this->dropTable('{{%v2_comments}}'); + $this->dropForeignKey('fk_v2_posts_created_by_id_users_id', '{{%v2_posts}}'); + $this->dropForeignKey('fk_v2_posts_category_id_categories_id', '{{%v2_posts}}'); + $this->dropTable('{{%v2_posts}}'); + $this->dropTable('{{%v2_users}}'); + $this->dropTable('{{%v2_categories}}'); + } + + private function dropTableIfExists(string $table) + { + $this->db->createCommand('DROP TABLE IF EXISTS ' . $this->db->quoteTableName($table)) + ->execute(); + } +} diff --git a/tests/migrations/m100000_000000_mysql.php b/tests/migrations/m100000_000000_mysql.php new file mode 100644 index 00000000..4b312036 --- /dev/null +++ b/tests/migrations/m100000_000000_mysql.php @@ -0,0 +1,112 @@ +db = 'mysql'; + parent::init(); + } + + public function up() + { + $this->dropTableIfExists('{{%v2_fakerable}}'); + $this->dropTableIfExists('{{%v2_comments}}'); + $this->dropTableIfExists('{{%v2_posts}}'); + $this->dropTableIfExists('{{%v2_users}}'); + $this->dropTableIfExists('{{%v2_categories}}'); + $this->createTable('{{%v2_categories}}', + [ + 'id' => $this->primaryKey(), + 'title' => $this->string(255)->notNull()->unique(), + 'active' => $this->boolean()->notNull()->defaultValue(false), + ]); + $this->createTable('{{%v2_users}}', + [ + 'id' => $this->primaryKey(), + 'username' => $this->string(200)->notNull()->unique(), + 'email' => $this->string(200)->notNull()->unique(), + 'password' => $this->string()->notNull(), + 'role' => $this->string(20)->null()->defaultValue('reader'), + 'created_at' => $this->timestamp()->null()->defaultExpression("CURRENT_TIMESTAMP"), + ]); + $this->createTable('{{%v2_posts}}', + [ + 'uid' => $this->bigPrimaryKey(), + 'title' => $this->string(255)->notNull()->unique(), + 'slug' => $this->string(200)->null()->defaultValue(null)->unique(), + 'category_id' => $this->integer()->notNull(), + 'active' => $this->boolean()->notNull()->defaultValue(false), + 'created_at' => $this->date()->null()->defaultValue(null), + 'created_by_id' => $this->integer()->null()->defaultValue(null), + ]); + $this->addForeignKey('fk_v2_posts_category_id_categories_id', + '{{%v2_posts}}', + 'category_id', + '{{%v2_categories}}', + 'id'); + $this->addForeignKey('fk_v2_posts_created_by_id_users_id', + '{{%v2_posts}}', + 'created_by_id', + '{{%v2_users}}', + 'id'); + $this->createTable('{{%v2_comments}}', + [ + 'id' => $this->bigPrimaryKey(), + 'post_id' => $this->bigInteger()->notNull(), + 'author_id' => $this->integer()->notNull(), + 'message' => $this->json()->notNull(), + 'created_at' => $this->integer()->notNull(), + ]); + $this->addForeignKey('fk_v2_comments_post_id_v2_posts_uid', + '{{%v2_comments}}', + 'post_id', + '{{%v2_posts}}', + 'uid'); + $this->addForeignKey('fk_v2_comments_author_id_v2_users_id', + '{{%v2_comments}}', + 'author_id', + '{{%v2_users}}', + 'id'); + $this->createTable('{{%v2_fakerable}}', [ + 'id' => $this->bigPrimaryKey(), + 'active' => $this->boolean()->null()->defaultValue(null), + 'floatval' => $this->float()->null()->defaultValue(null), + 'floatval_lim' => $this->float()->null()->defaultValue(null), + 'doubleval' => $this->double()->null()->defaultValue(null), + 'int_min' => $this->integer()->null()->defaultValue(3), + 'int_max' => $this->integer()->null()->defaultValue(null), + 'int_minmax' => $this->integer()->null()->defaultValue(null), + 'int_created_at' => $this->integer()->null()->defaultValue(null), + 'int_simple' => $this->integer()->null()->defaultValue(null), + 'str_text' => $this->text()->null(), + 'str_varchar' => $this->string(100)->null()->defaultValue(null), + 'str_date' => $this->date()->null()->defaultValue(null), + 'str_datetime' => $this->timestamp()->null()->defaultValue(null), + 'str_country' => $this->text()->null(), + ]); + } + + public function down() + { + $this->dropTable('{{%v2_fakerable}}'); + $this->dropForeignKey('fk_v2_comments_author_id_v2_users_id', '{{%v2_comments}}'); + $this->dropForeignKey('fk_v2_comments_post_id_v2_posts_uid', '{{%v2_comments}}'); + $this->dropTable('{{%v2_comments}}'); + $this->dropForeignKey('fk_v2_posts_created_by_id_users_id', '{{%v2_posts}}'); + $this->dropForeignKey('fk_v2_posts_category_id_categories_id', '{{%v2_posts}}'); + $this->dropTable('{{%v2_posts}}'); + $this->dropTable('{{%v2_users}}'); + $this->dropTable('{{%v2_categories}}'); + } + + private function dropTableIfExists(string $table) + { + $this->db->createCommand('DROP TABLE IF EXISTS ' . $this->db->quoteTableName($table)) + ->execute(); + } +} diff --git a/tests/migrations/m100000_000000_pgsql.php b/tests/migrations/m100000_000000_pgsql.php new file mode 100644 index 00000000..f3d0c75c --- /dev/null +++ b/tests/migrations/m100000_000000_pgsql.php @@ -0,0 +1,102 @@ +db = 'pgsql'; + parent::init(); + } + + public function safeUp() + { + $this->createTable('{{%v2_categories}}', + [ + 'id' => $this->primaryKey(), + 'title' => $this->string(255)->notNull()->unique(), + 'active' => $this->boolean()->notNull()->defaultValue(false), + ]); + $this->createTable('{{%v2_users}}', + [ + 'id' => $this->primaryKey(), + 'username' => $this->string(200)->notNull()->unique(), + 'email' => $this->string(200)->notNull()->unique(), + 'password' => $this->string()->notNull(), + 'role' => $this->string(20)->null()->defaultValue('reader'), + 'created_at' => $this->timestamp()->null()->defaultExpression("CURRENT_TIMESTAMP"), + ]); + $this->createTable('{{%v2_posts}}', + [ + 'uid' => $this->bigPrimaryKey(), + 'title' => $this->string(255)->notNull()->unique(), + 'slug' => $this->string(200)->null()->defaultValue(null)->unique(), + 'category_id' => $this->integer()->notNull(), + 'active' => $this->boolean()->notNull()->defaultValue(false), + 'created_at' => $this->date()->null()->defaultValue(null), + 'created_by_id' => $this->integer()->null()->defaultValue(null), + ]); + $this->addForeignKey('fk_v2_posts_category_id_categories_id', + '{{%v2_posts}}', + 'category_id', + '{{%v2_categories}}', + 'id'); + $this->addForeignKey('fk_v2_posts_created_by_id_users_id', + '{{%v2_posts}}', + 'created_by_id', + '{{%v2_users}}', + 'id'); + $this->createTable('{{%v2_comments}}', + [ + 'id' => $this->bigPrimaryKey(), + 'post_id' => $this->bigInteger()->notNull(), + 'author_id' => $this->integer()->notNull(), + 'message' => $this->json()->notNull()->defaultValue('{}'), + 'created_at' => $this->integer()->notNull(), + ]); + $this->addForeignKey('fk_v2_comments_post_id_v2_posts_uid', + '{{%v2_comments}}', + 'post_id', + '{{%v2_posts}}', + 'uid'); + $this->addForeignKey('fk_v2_comments_author_id_v2_users_id', + '{{%v2_comments}}', + 'author_id', + '{{%v2_users}}', + 'id'); + $this->createTable('{{%v2_fakerable}}', [ + 'id' => $this->bigPrimaryKey(), + 'active' => $this->boolean()->null()->defaultValue(null), + 'floatval' => $this->float()->null()->defaultValue(null), + 'floatval_lim' => $this->float()->null()->defaultValue(null), + 'doubleval' => $this->double()->null()->defaultValue(null), + 'int_min' => $this->integer()->null()->defaultValue(3), + 'int_max' => $this->integer()->null()->defaultValue(null), + 'int_minmax' => $this->integer()->null()->defaultValue(null), + 'int_created_at' => $this->integer()->null()->defaultValue(null), + 'int_simple' => $this->integer()->null()->defaultValue(null), + 'uuid' => 'uuid NULL DEFAULT NULL', + 'str_text' => $this->text()->null()->defaultValue(null), + 'str_varchar' => $this->string(100)->null()->defaultValue(null), + 'str_date' => $this->date()->null()->defaultValue(null), + 'str_datetime' => $this->timestamp()->null()->defaultValue(null), + 'str_country' => $this->text()->null()->defaultValue(null), + ]); + } + + public function safeDown() + { + $this->dropTable('{{%v2_fakerable}}'); + $this->dropForeignKey('fk_v2_comments_author_id_v2_users_id', '{{%v2_comments}}'); + $this->dropForeignKey('fk_v2_comments_post_id_v2_posts_uid', '{{%v2_comments}}'); + $this->dropTable('{{%v2_comments}}'); + $this->dropForeignKey('fk_v2_posts_created_by_id_users_id', '{{%v2_posts}}'); + $this->dropForeignKey('fk_v2_posts_category_id_categories_id', '{{%v2_posts}}'); + $this->dropTable('{{%v2_posts}}'); + $this->dropTable('{{%v2_users}}'); + $this->dropTable('{{%v2_categories}}'); + } +} diff --git a/tests/specs/blog.php b/tests/specs/blog.php new file mode 100644 index 00000000..b767cf1e --- /dev/null +++ b/tests/specs/blog.php @@ -0,0 +1,11 @@ + '@specs/blog.yaml', + 'generateUrls' => false, + 'generateControllers' => false, + 'generateModels' => true, + 'generateMigrations' => true, + 'excludeModels' => [ + 'Error', + ], +]; \ No newline at end of file diff --git a/tests/specs/blog.yaml b/tests/specs/blog.yaml new file mode 100644 index 00000000..74de26fd --- /dev/null +++ b/tests/specs/blog.yaml @@ -0,0 +1,238 @@ +openapi: "3.0.0" +info: + version: 1.0.0 + title: Blog prototype for test migrations + license: + name: MIT +servers: + - url: http://blog.dummy.io/v1 +paths: + /posts: + get: + summary: List all posts + operationId: listPosts + tags: + - posts + parameters: + - name: limit + in: query + description: How many items to return at one time (max 100) + required: false + schema: + type: integer + format: int32 + responses: + '200': + description: A paged array of posts + headers: + x-next: + description: A link to the next page of responses + schema: + type: string + content: + application/json: + schema: + $ref: "#/components/schemas/Posts" + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" +components: + schemas: + User: + description: The User + required: + - id + - username + - email + - password + properties: + id: + type: integer + format: int32 + readOnly: True + username: + type: string + maxLength: 200 + x-db-unique: 1 + email: + type: string + maxLength: 200 + x-db-unique: 1 + password: + type: string + format: password + x-db-type: string + role: + type: string + maxLength: 20 + x-faker: "$faker->randomElement(['admin', 'editor', 'reader'])" + default: reader + created_at: + type: string + format: date-time + default: CURRENT_TIMESTAMP + Users: + type: array + items: + $ref: "#/components/schemas/User" + Category: + description: Category of posts + required: + - id + - title + - active + properties: + id: + type: integer + format: int32 + readOnly: True + title: + type: string + maxLength: 255 + x-db-unique: true + active: + type: boolean + default: false + posts: + type: array + items: + $ref: "#/components/schemas/Post" + Categories: + type: array + items: + $ref: "#/components/schemas/Category" + Post: + x-table: blog_posts + x-pk: uid + description: A blog post (uid used as pk for test purposes) + required: + - uid + - title + - category + - author + - active + properties: + uid: + type: integer + format: int64 + readOnly: True + title: + type: string + x-db-unique: true + maxLength: 255 + slug: + type: string + minLength: 1 + maxLength: 200 + x-db-unique: true + category: + $ref: "#/components/schemas/Category" + active: + type: boolean + default: false + created_at: + type: string + format: date + created_by: + $ref: "#/components/schemas/User" + comments: + type: array + items: + $ref: "#/components/schemas/Comment" + Posts: + type: array + items: + $ref: "#/components/schemas/Post" + Comment: + x-table: post_comments + required: + - id + - post + - author + - message + - created_at + properties: + id: + type: integer + format: int64 + readOnly: True + post: + $ref: "#/components/schemas/Post" + author: + $ref: "#/components/schemas/User" + message: + type: string + x-db-type: json + default: '{}' + created_at: + type: integer + format: int32 + Comments: + type: array + items: + $ref: "#/components/schemas/Comment" + Fakerable: + x-table: fakerable + properties: + id: + type: integer + format: int64 + readOnly: True + active: + type: boolean + floatval: + type: number + format: float + floatval_lim: + type: number + format: float + minimum: 0 + maximum: 1 + doubleval: + type: number + format: double + int_min: + type: integer + minimum: 5 + default: 3 + int_max: + type: integer + maximum: 5 + int_minmax: + type: integer + minimum: 5 + maximum: 25 + int_created_at: + type: integer + int_simple: + type: integer + uuid: + type: string + x-db-type: UUID + x-faker: '$faker->uuid' + str_text: + type: string + str_varchar: + type: string + maxLength: 100 + str_date: + type: string + format: date + str_datetime: + type: string + format: date-time + str_country: + type: string + Error: + required: + - code + - message + properties: + code: + type: integer + format: int32 + message: + type: string diff --git a/tests/specs/blog/migrations/m200000_000000_create_table_categories.php b/tests/specs/blog/migrations/m200000_000000_create_table_categories.php new file mode 100644 index 00000000..2d9ac79f --- /dev/null +++ b/tests/specs/blog/migrations/m200000_000000_create_table_categories.php @@ -0,0 +1,21 @@ +createTable('{{%categories}}', [ + 'id' => $this->primaryKey(), + 'title' => $this->string(255)->notNull()->unique(), + 'active' => $this->boolean()->notNull()->defaultValue(false), + ]); + } + + public function down() + { + $this->dropTable('{{%categories}}'); + } +} diff --git a/tests/specs/blog/migrations/m200000_000001_create_table_users.php b/tests/specs/blog/migrations/m200000_000001_create_table_users.php new file mode 100644 index 00000000..f395e8c4 --- /dev/null +++ b/tests/specs/blog/migrations/m200000_000001_create_table_users.php @@ -0,0 +1,24 @@ +createTable('{{%users}}', [ + 'id' => $this->primaryKey(), + 'username' => $this->string(200)->notNull()->unique(), + 'email' => $this->string(200)->notNull()->unique(), + 'password' => $this->string()->notNull(), + 'role' => $this->string(20)->null()->defaultValue("reader"), + 'created_at' => $this->timestamp()->null()->defaultExpression("CURRENT_TIMESTAMP"), + ]); + } + + public function down() + { + $this->dropTable('{{%users}}'); + } +} diff --git a/tests/specs/blog/migrations/m200000_000002_create_table_blog_posts.php b/tests/specs/blog/migrations/m200000_000002_create_table_blog_posts.php new file mode 100644 index 00000000..0e1284d0 --- /dev/null +++ b/tests/specs/blog/migrations/m200000_000002_create_table_blog_posts.php @@ -0,0 +1,29 @@ +createTable('{{%blog_posts}}', [ + 'uid' => $this->bigPrimaryKey(), + 'title' => $this->string(255)->notNull()->unique(), + 'slug' => $this->string(200)->null()->defaultValue(null)->unique(), + 'category_id' => $this->integer()->notNull(), + 'active' => $this->boolean()->notNull()->defaultValue(false), + 'created_at' => $this->date()->null()->defaultValue(null), + 'created_by_id' => $this->integer()->null()->defaultValue(null), + ]); + $this->addForeignKey('fk_blog_posts_category_id_categories_id', '{{%blog_posts}}', 'category_id', '{{%categories}}', 'id'); + $this->addForeignKey('fk_blog_posts_created_by_id_users_id', '{{%blog_posts}}', 'created_by_id', '{{%users}}', 'id'); + } + + public function down() + { + $this->dropForeignKey('fk_blog_posts_created_by_id_users_id', '{{%blog_posts}}'); + $this->dropForeignKey('fk_blog_posts_category_id_categories_id', '{{%blog_posts}}'); + $this->dropTable('{{%blog_posts}}'); + } +} diff --git a/tests/specs/blog/migrations/m200000_000003_create_table_fakerable.php b/tests/specs/blog/migrations/m200000_000003_create_table_fakerable.php new file mode 100644 index 00000000..b86b5181 --- /dev/null +++ b/tests/specs/blog/migrations/m200000_000003_create_table_fakerable.php @@ -0,0 +1,34 @@ +createTable('{{%fakerable}}', [ + 'id' => $this->bigPrimaryKey(), + 'active' => $this->boolean()->null()->defaultValue(null), + 'floatval' => $this->float()->null()->defaultValue(null), + 'floatval_lim' => $this->float()->null()->defaultValue(null), + 'doubleval' => $this->double()->null()->defaultValue(null), + 'int_min' => $this->integer()->null()->defaultValue(3), + 'int_max' => $this->integer()->null()->defaultValue(null), + 'int_minmax' => $this->integer()->null()->defaultValue(null), + 'int_created_at' => $this->integer()->null()->defaultValue(null), + 'int_simple' => $this->integer()->null()->defaultValue(null), + 'uuid' => 'uuid NULL DEFAULT NULL', + 'str_text' => $this->text()->null()->defaultValue(null), + 'str_varchar' => $this->string(100)->null()->defaultValue(null), + 'str_date' => $this->date()->null()->defaultValue(null), + 'str_datetime' => $this->timestamp()->null()->defaultValue(null), + 'str_country' => $this->text()->null()->defaultValue(null), + ]); + } + + public function down() + { + $this->dropTable('{{%fakerable}}'); + } +} diff --git a/tests/specs/blog/migrations/m200000_000004_create_table_post_comments.php b/tests/specs/blog/migrations/m200000_000004_create_table_post_comments.php new file mode 100644 index 00000000..7ed94036 --- /dev/null +++ b/tests/specs/blog/migrations/m200000_000004_create_table_post_comments.php @@ -0,0 +1,27 @@ +createTable('{{%post_comments}}', [ + 'id' => $this->bigPrimaryKey(), + 'post_id' => $this->bigInteger()->notNull(), + 'author_id' => $this->integer()->notNull(), + 'message' => 'json NOT NULL DEFAULT \'{}\'', + 'created_at' => $this->integer()->notNull(), + ]); + $this->addForeignKey('fk_post_comments_post_id_blog_posts_uid', '{{%post_comments}}', 'post_id', '{{%blog_posts}}', 'uid'); + $this->addForeignKey('fk_post_comments_author_id_users_id', '{{%post_comments}}', 'author_id', '{{%users}}', 'id'); + } + + public function down() + { + $this->dropForeignKey('fk_post_comments_author_id_users_id', '{{%post_comments}}'); + $this->dropForeignKey('fk_post_comments_post_id_blog_posts_uid', '{{%post_comments}}'); + $this->dropTable('{{%post_comments}}'); + } +} diff --git a/tests/specs/blog/migrations_maria_db/m200000_000000_create_table_categories.php b/tests/specs/blog/migrations_maria_db/m200000_000000_create_table_categories.php new file mode 100644 index 00000000..2d9ac79f --- /dev/null +++ b/tests/specs/blog/migrations_maria_db/m200000_000000_create_table_categories.php @@ -0,0 +1,21 @@ +createTable('{{%categories}}', [ + 'id' => $this->primaryKey(), + 'title' => $this->string(255)->notNull()->unique(), + 'active' => $this->boolean()->notNull()->defaultValue(false), + ]); + } + + public function down() + { + $this->dropTable('{{%categories}}'); + } +} diff --git a/tests/specs/blog/migrations_maria_db/m200000_000001_create_table_users.php b/tests/specs/blog/migrations_maria_db/m200000_000001_create_table_users.php new file mode 100644 index 00000000..f395e8c4 --- /dev/null +++ b/tests/specs/blog/migrations_maria_db/m200000_000001_create_table_users.php @@ -0,0 +1,24 @@ +createTable('{{%users}}', [ + 'id' => $this->primaryKey(), + 'username' => $this->string(200)->notNull()->unique(), + 'email' => $this->string(200)->notNull()->unique(), + 'password' => $this->string()->notNull(), + 'role' => $this->string(20)->null()->defaultValue("reader"), + 'created_at' => $this->timestamp()->null()->defaultExpression("CURRENT_TIMESTAMP"), + ]); + } + + public function down() + { + $this->dropTable('{{%users}}'); + } +} diff --git a/tests/specs/blog/migrations_maria_db/m200000_000002_create_table_blog_posts.php b/tests/specs/blog/migrations_maria_db/m200000_000002_create_table_blog_posts.php new file mode 100644 index 00000000..0e1284d0 --- /dev/null +++ b/tests/specs/blog/migrations_maria_db/m200000_000002_create_table_blog_posts.php @@ -0,0 +1,29 @@ +createTable('{{%blog_posts}}', [ + 'uid' => $this->bigPrimaryKey(), + 'title' => $this->string(255)->notNull()->unique(), + 'slug' => $this->string(200)->null()->defaultValue(null)->unique(), + 'category_id' => $this->integer()->notNull(), + 'active' => $this->boolean()->notNull()->defaultValue(false), + 'created_at' => $this->date()->null()->defaultValue(null), + 'created_by_id' => $this->integer()->null()->defaultValue(null), + ]); + $this->addForeignKey('fk_blog_posts_category_id_categories_id', '{{%blog_posts}}', 'category_id', '{{%categories}}', 'id'); + $this->addForeignKey('fk_blog_posts_created_by_id_users_id', '{{%blog_posts}}', 'created_by_id', '{{%users}}', 'id'); + } + + public function down() + { + $this->dropForeignKey('fk_blog_posts_created_by_id_users_id', '{{%blog_posts}}'); + $this->dropForeignKey('fk_blog_posts_category_id_categories_id', '{{%blog_posts}}'); + $this->dropTable('{{%blog_posts}}'); + } +} diff --git a/tests/specs/blog/migrations_maria_db/m200000_000003_create_table_fakerable.php b/tests/specs/blog/migrations_maria_db/m200000_000003_create_table_fakerable.php new file mode 100644 index 00000000..b86b5181 --- /dev/null +++ b/tests/specs/blog/migrations_maria_db/m200000_000003_create_table_fakerable.php @@ -0,0 +1,34 @@ +createTable('{{%fakerable}}', [ + 'id' => $this->bigPrimaryKey(), + 'active' => $this->boolean()->null()->defaultValue(null), + 'floatval' => $this->float()->null()->defaultValue(null), + 'floatval_lim' => $this->float()->null()->defaultValue(null), + 'doubleval' => $this->double()->null()->defaultValue(null), + 'int_min' => $this->integer()->null()->defaultValue(3), + 'int_max' => $this->integer()->null()->defaultValue(null), + 'int_minmax' => $this->integer()->null()->defaultValue(null), + 'int_created_at' => $this->integer()->null()->defaultValue(null), + 'int_simple' => $this->integer()->null()->defaultValue(null), + 'uuid' => 'uuid NULL DEFAULT NULL', + 'str_text' => $this->text()->null()->defaultValue(null), + 'str_varchar' => $this->string(100)->null()->defaultValue(null), + 'str_date' => $this->date()->null()->defaultValue(null), + 'str_datetime' => $this->timestamp()->null()->defaultValue(null), + 'str_country' => $this->text()->null()->defaultValue(null), + ]); + } + + public function down() + { + $this->dropTable('{{%fakerable}}'); + } +} diff --git a/tests/specs/blog/migrations_maria_db/m200000_000004_create_table_post_comments.php b/tests/specs/blog/migrations_maria_db/m200000_000004_create_table_post_comments.php new file mode 100644 index 00000000..7ed94036 --- /dev/null +++ b/tests/specs/blog/migrations_maria_db/m200000_000004_create_table_post_comments.php @@ -0,0 +1,27 @@ +createTable('{{%post_comments}}', [ + 'id' => $this->bigPrimaryKey(), + 'post_id' => $this->bigInteger()->notNull(), + 'author_id' => $this->integer()->notNull(), + 'message' => 'json NOT NULL DEFAULT \'{}\'', + 'created_at' => $this->integer()->notNull(), + ]); + $this->addForeignKey('fk_post_comments_post_id_blog_posts_uid', '{{%post_comments}}', 'post_id', '{{%blog_posts}}', 'uid'); + $this->addForeignKey('fk_post_comments_author_id_users_id', '{{%post_comments}}', 'author_id', '{{%users}}', 'id'); + } + + public function down() + { + $this->dropForeignKey('fk_post_comments_author_id_users_id', '{{%post_comments}}'); + $this->dropForeignKey('fk_post_comments_post_id_blog_posts_uid', '{{%post_comments}}'); + $this->dropTable('{{%post_comments}}'); + } +} diff --git a/tests/specs/blog/migrations_mysql_db/m200000_000000_create_table_categories.php b/tests/specs/blog/migrations_mysql_db/m200000_000000_create_table_categories.php new file mode 100644 index 00000000..2d9ac79f --- /dev/null +++ b/tests/specs/blog/migrations_mysql_db/m200000_000000_create_table_categories.php @@ -0,0 +1,21 @@ +createTable('{{%categories}}', [ + 'id' => $this->primaryKey(), + 'title' => $this->string(255)->notNull()->unique(), + 'active' => $this->boolean()->notNull()->defaultValue(false), + ]); + } + + public function down() + { + $this->dropTable('{{%categories}}'); + } +} diff --git a/tests/specs/blog/migrations_mysql_db/m200000_000001_create_table_users.php b/tests/specs/blog/migrations_mysql_db/m200000_000001_create_table_users.php new file mode 100644 index 00000000..f395e8c4 --- /dev/null +++ b/tests/specs/blog/migrations_mysql_db/m200000_000001_create_table_users.php @@ -0,0 +1,24 @@ +createTable('{{%users}}', [ + 'id' => $this->primaryKey(), + 'username' => $this->string(200)->notNull()->unique(), + 'email' => $this->string(200)->notNull()->unique(), + 'password' => $this->string()->notNull(), + 'role' => $this->string(20)->null()->defaultValue("reader"), + 'created_at' => $this->timestamp()->null()->defaultExpression("CURRENT_TIMESTAMP"), + ]); + } + + public function down() + { + $this->dropTable('{{%users}}'); + } +} diff --git a/tests/specs/blog/migrations_mysql_db/m200000_000002_create_table_blog_posts.php b/tests/specs/blog/migrations_mysql_db/m200000_000002_create_table_blog_posts.php new file mode 100644 index 00000000..0e1284d0 --- /dev/null +++ b/tests/specs/blog/migrations_mysql_db/m200000_000002_create_table_blog_posts.php @@ -0,0 +1,29 @@ +createTable('{{%blog_posts}}', [ + 'uid' => $this->bigPrimaryKey(), + 'title' => $this->string(255)->notNull()->unique(), + 'slug' => $this->string(200)->null()->defaultValue(null)->unique(), + 'category_id' => $this->integer()->notNull(), + 'active' => $this->boolean()->notNull()->defaultValue(false), + 'created_at' => $this->date()->null()->defaultValue(null), + 'created_by_id' => $this->integer()->null()->defaultValue(null), + ]); + $this->addForeignKey('fk_blog_posts_category_id_categories_id', '{{%blog_posts}}', 'category_id', '{{%categories}}', 'id'); + $this->addForeignKey('fk_blog_posts_created_by_id_users_id', '{{%blog_posts}}', 'created_by_id', '{{%users}}', 'id'); + } + + public function down() + { + $this->dropForeignKey('fk_blog_posts_created_by_id_users_id', '{{%blog_posts}}'); + $this->dropForeignKey('fk_blog_posts_category_id_categories_id', '{{%blog_posts}}'); + $this->dropTable('{{%blog_posts}}'); + } +} diff --git a/tests/specs/blog/migrations_mysql_db/m200000_000003_create_table_fakerable.php b/tests/specs/blog/migrations_mysql_db/m200000_000003_create_table_fakerable.php new file mode 100644 index 00000000..31485c92 --- /dev/null +++ b/tests/specs/blog/migrations_mysql_db/m200000_000003_create_table_fakerable.php @@ -0,0 +1,34 @@ +createTable('{{%fakerable}}', [ + 'id' => $this->bigPrimaryKey(), + 'active' => $this->boolean()->null()->defaultValue(null), + 'floatval' => $this->float()->null()->defaultValue(null), + 'floatval_lim' => $this->float()->null()->defaultValue(null), + 'doubleval' => $this->double()->null()->defaultValue(null), + 'int_min' => $this->integer()->null()->defaultValue(3), + 'int_max' => $this->integer()->null()->defaultValue(null), + 'int_minmax' => $this->integer()->null()->defaultValue(null), + 'int_created_at' => $this->integer()->null()->defaultValue(null), + 'int_simple' => $this->integer()->null()->defaultValue(null), + 'uuid' => 'uuid NULL DEFAULT NULL', + 'str_text' => $this->text()->null(), + 'str_varchar' => $this->string(100)->null()->defaultValue(null), + 'str_date' => $this->date()->null()->defaultValue(null), + 'str_datetime' => $this->timestamp()->null()->defaultValue(null), + 'str_country' => $this->text()->null(), + ]); + } + + public function down() + { + $this->dropTable('{{%fakerable}}'); + } +} diff --git a/tests/specs/blog/migrations_mysql_db/m200000_000004_create_table_post_comments.php b/tests/specs/blog/migrations_mysql_db/m200000_000004_create_table_post_comments.php new file mode 100644 index 00000000..635ad043 --- /dev/null +++ b/tests/specs/blog/migrations_mysql_db/m200000_000004_create_table_post_comments.php @@ -0,0 +1,27 @@ +createTable('{{%post_comments}}', [ + 'id' => $this->bigPrimaryKey(), + 'post_id' => $this->bigInteger()->notNull(), + 'author_id' => $this->integer()->notNull(), + 'message' => 'json NOT NULL', + 'created_at' => $this->integer()->notNull(), + ]); + $this->addForeignKey('fk_post_comments_post_id_blog_posts_uid', '{{%post_comments}}', 'post_id', '{{%blog_posts}}', 'uid'); + $this->addForeignKey('fk_post_comments_author_id_users_id', '{{%post_comments}}', 'author_id', '{{%users}}', 'id'); + } + + public function down() + { + $this->dropForeignKey('fk_post_comments_author_id_users_id', '{{%post_comments}}'); + $this->dropForeignKey('fk_post_comments_post_id_blog_posts_uid', '{{%post_comments}}'); + $this->dropTable('{{%post_comments}}'); + } +} diff --git a/tests/specs/blog/migrations_pgsql_db/m200000_000000_create_table_categories.php b/tests/specs/blog/migrations_pgsql_db/m200000_000000_create_table_categories.php new file mode 100644 index 00000000..76d3c5fe --- /dev/null +++ b/tests/specs/blog/migrations_pgsql_db/m200000_000000_create_table_categories.php @@ -0,0 +1,21 @@ +createTable('{{%categories}}', [ + 'id' => $this->primaryKey(), + 'title' => $this->string(255)->notNull()->unique(), + 'active' => $this->boolean()->notNull()->defaultValue(false), + ]); + } + + public function safeDown() + { + $this->dropTable('{{%categories}}'); + } +} diff --git a/tests/specs/blog/migrations_pgsql_db/m200000_000001_create_table_users.php b/tests/specs/blog/migrations_pgsql_db/m200000_000001_create_table_users.php new file mode 100644 index 00000000..499ad78c --- /dev/null +++ b/tests/specs/blog/migrations_pgsql_db/m200000_000001_create_table_users.php @@ -0,0 +1,24 @@ +createTable('{{%users}}', [ + 'id' => $this->primaryKey(), + 'username' => $this->string(200)->notNull()->unique(), + 'email' => $this->string(200)->notNull()->unique(), + 'password' => $this->string()->notNull(), + 'role' => $this->string(20)->null()->defaultValue("reader"), + 'created_at' => $this->timestamp()->null()->defaultExpression("CURRENT_TIMESTAMP"), + ]); + } + + public function safeDown() + { + $this->dropTable('{{%users}}'); + } +} diff --git a/tests/specs/blog/migrations_pgsql_db/m200000_000002_create_table_blog_posts.php b/tests/specs/blog/migrations_pgsql_db/m200000_000002_create_table_blog_posts.php new file mode 100644 index 00000000..eac7bd6f --- /dev/null +++ b/tests/specs/blog/migrations_pgsql_db/m200000_000002_create_table_blog_posts.php @@ -0,0 +1,29 @@ +createTable('{{%blog_posts}}', [ + 'uid' => $this->bigPrimaryKey(), + 'title' => $this->string(255)->notNull()->unique(), + 'slug' => $this->string(200)->null()->defaultValue(null)->unique(), + 'category_id' => $this->integer()->notNull(), + 'active' => $this->boolean()->notNull()->defaultValue(false), + 'created_at' => $this->date()->null()->defaultValue(null), + 'created_by_id' => $this->integer()->null()->defaultValue(null), + ]); + $this->addForeignKey('fk_blog_posts_category_id_categories_id', '{{%blog_posts}}', 'category_id', '{{%categories}}', 'id'); + $this->addForeignKey('fk_blog_posts_created_by_id_users_id', '{{%blog_posts}}', 'created_by_id', '{{%users}}', 'id'); + } + + public function safeDown() + { + $this->dropForeignKey('fk_blog_posts_created_by_id_users_id', '{{%blog_posts}}'); + $this->dropForeignKey('fk_blog_posts_category_id_categories_id', '{{%blog_posts}}'); + $this->dropTable('{{%blog_posts}}'); + } +} diff --git a/tests/specs/blog/migrations_pgsql_db/m200000_000003_create_table_fakerable.php b/tests/specs/blog/migrations_pgsql_db/m200000_000003_create_table_fakerable.php new file mode 100644 index 00000000..650fd2c5 --- /dev/null +++ b/tests/specs/blog/migrations_pgsql_db/m200000_000003_create_table_fakerable.php @@ -0,0 +1,34 @@ +createTable('{{%fakerable}}', [ + 'id' => $this->bigPrimaryKey(), + 'active' => $this->boolean()->null()->defaultValue(null), + 'floatval' => $this->float()->null()->defaultValue(null), + 'floatval_lim' => $this->float()->null()->defaultValue(null), + 'doubleval' => $this->double()->null()->defaultValue(null), + 'int_min' => $this->integer()->null()->defaultValue(3), + 'int_max' => $this->integer()->null()->defaultValue(null), + 'int_minmax' => $this->integer()->null()->defaultValue(null), + 'int_created_at' => $this->integer()->null()->defaultValue(null), + 'int_simple' => $this->integer()->null()->defaultValue(null), + 'uuid' => 'uuid NULL DEFAULT NULL', + 'str_text' => $this->text()->null()->defaultValue(null), + 'str_varchar' => $this->string(100)->null()->defaultValue(null), + 'str_date' => $this->date()->null()->defaultValue(null), + 'str_datetime' => $this->timestamp()->null()->defaultValue(null), + 'str_country' => $this->text()->null()->defaultValue(null), + ]); + } + + public function safeDown() + { + $this->dropTable('{{%fakerable}}'); + } +} diff --git a/tests/specs/blog/migrations_pgsql_db/m200000_000004_create_table_post_comments.php b/tests/specs/blog/migrations_pgsql_db/m200000_000004_create_table_post_comments.php new file mode 100644 index 00000000..380a57e6 --- /dev/null +++ b/tests/specs/blog/migrations_pgsql_db/m200000_000004_create_table_post_comments.php @@ -0,0 +1,27 @@ +createTable('{{%post_comments}}', [ + 'id' => $this->bigPrimaryKey(), + 'post_id' => $this->bigInteger()->notNull(), + 'author_id' => $this->integer()->notNull(), + 'message' => 'json NOT NULL DEFAULT \'{}\'', + 'created_at' => $this->integer()->notNull(), + ]); + $this->addForeignKey('fk_post_comments_post_id_blog_posts_uid', '{{%post_comments}}', 'post_id', '{{%blog_posts}}', 'uid'); + $this->addForeignKey('fk_post_comments_author_id_users_id', '{{%post_comments}}', 'author_id', '{{%users}}', 'id'); + } + + public function safeDown() + { + $this->dropForeignKey('fk_post_comments_author_id_users_id', '{{%post_comments}}'); + $this->dropForeignKey('fk_post_comments_post_id_blog_posts_uid', '{{%post_comments}}'); + $this->dropTable('{{%post_comments}}'); + } +} diff --git a/tests/specs/blog/models/Category.php b/tests/specs/blog/models/Category.php new file mode 100644 index 00000000..6e9d01c6 --- /dev/null +++ b/tests/specs/blog/models/Category.php @@ -0,0 +1,10 @@ +language); + $uniqueFaker = new UniqueGenerator($faker); + $model = new Category(); + $model->id = $uniqueFaker->numberBetween(0, 2147483647); + $model->title = substr($faker->sentence, 0, 255); + $model->active = $faker->boolean; + return $model; + } +} diff --git a/tests/specs/blog/models/Comment.php b/tests/specs/blog/models/Comment.php new file mode 100644 index 00000000..7a4886f7 --- /dev/null +++ b/tests/specs/blog/models/Comment.php @@ -0,0 +1,10 @@ +language); + $uniqueFaker = new UniqueGenerator($faker); + $model = new Comment(); + $model->id = $uniqueFaker->numberBetween(0, 2147483647); + $model->message = []; + $model->created_at = $faker->unixTime; + return $model; + } +} diff --git a/tests/specs/blog/models/Fakerable.php b/tests/specs/blog/models/Fakerable.php new file mode 100644 index 00000000..5083a385 --- /dev/null +++ b/tests/specs/blog/models/Fakerable.php @@ -0,0 +1,10 @@ +language); + $uniqueFaker = new UniqueGenerator($faker); + $model = new Fakerable(); + $model->id = $uniqueFaker->numberBetween(0, 2147483647); + $model->active = $faker->boolean; + $model->floatval = $faker->randomFloat(); + $model->floatval_lim = $faker->randomFloat(null, 0, 1); + $model->doubleval = $faker->randomFloat(); + $model->int_min = $faker->numberBetween(5, 2147483647); + $model->int_max = $faker->numberBetween(0, 5); + $model->int_minmax = $faker->numberBetween(5, 25); + $model->int_created_at = $faker->unixTime; + $model->int_simple = $faker->numberBetween(0, 2147483647); + $model->uuid = $faker->uuid; + $model->str_text = $faker->sentence; + $model->str_varchar = substr($faker->text(100), 0, 100); + $model->str_date = $faker->iso8601; + $model->str_datetime = $faker->dateTimeThisCentury->format('Y-m-d H:i:s'); + $model->str_country = $faker->countryCode; + return $model; + } +} diff --git a/tests/specs/blog/models/Post.php b/tests/specs/blog/models/Post.php new file mode 100644 index 00000000..2825fe31 --- /dev/null +++ b/tests/specs/blog/models/Post.php @@ -0,0 +1,10 @@ +language); + $uniqueFaker = new UniqueGenerator($faker); + $model = new Post(); + $model->uid = $uniqueFaker->numberBetween(0, 2147483647); + $model->title = substr($faker->sentence, 0, 255); + $model->slug = substr($uniqueFaker->slug, 0, 200); + $model->active = $faker->boolean; + $model->created_at = $faker->iso8601; + return $model; + } +} diff --git a/tests/specs/blog/models/User.php b/tests/specs/blog/models/User.php new file mode 100644 index 00000000..9b837d6e --- /dev/null +++ b/tests/specs/blog/models/User.php @@ -0,0 +1,10 @@ +language); + $uniqueFaker = new UniqueGenerator($faker); + $model = new User(); + $model->id = $uniqueFaker->numberBetween(0, 2147483647); + $model->username = substr($faker->userName, 0, 200); + $model->email = substr($faker->safeEmail, 0, 200); + $model->password = $faker->password; + $model->role = $faker->randomElement(['admin', 'editor', 'reader']); + $model->created_at = $faker->dateTimeThisCentury->format('Y-m-d H:i:s'); + return $model; + } +} diff --git a/tests/specs/blog/models/base/Category.php b/tests/specs/blog/models/base/Category.php new file mode 100644 index 00000000..aa670e13 --- /dev/null +++ b/tests/specs/blog/models/base/Category.php @@ -0,0 +1,35 @@ +hasMany(\app\models\Post::class,['category_id' => 'id']); + } +} diff --git a/tests/specs/blog/models/base/Comment.php b/tests/specs/blog/models/base/Comment.php new file mode 100644 index 00000000..77d79f0a --- /dev/null +++ b/tests/specs/blog/models/base/Comment.php @@ -0,0 +1,43 @@ + 'Post'], + [['author_id'], 'exist', 'targetRelation' => 'Author'], + [['message'], 'safe'], + ]; + } + + public function getPost() + { + return $this->hasOne(\app\models\Post::class,['uid' => 'post_id']); + } + public function getAuthor() + { + return $this->hasOne(\app\models\User::class,['id' => 'author_id']); + } +} diff --git a/tests/specs/blog/models/base/Fakerable.php b/tests/specs/blog/models/base/Fakerable.php new file mode 100644 index 00000000..36171d90 --- /dev/null +++ b/tests/specs/blog/models/base/Fakerable.php @@ -0,0 +1,44 @@ + 'Category'], + [['created_by_id'], 'exist', 'targetRelation' => 'CreatedBy'], + [['title', 'slug', 'created_at'], 'string'], + [['active'], 'boolean'], + ]; + } + + public function getCategory() + { + return $this->hasOne(\app\models\Category::class,['id' => 'category_id']); + } + public function getCreatedBy() + { + return $this->hasOne(\app\models\User::class,['id' => 'created_by_id']); + } + public function getComments() + { + return $this->hasMany(\app\models\Comment::class,['post_id' => 'uid']); + } +} diff --git a/tests/specs/blog/models/base/User.php b/tests/specs/blog/models/base/User.php new file mode 100644 index 00000000..ae3da1f0 --- /dev/null +++ b/tests/specs/blog/models/base/User.php @@ -0,0 +1,32 @@ + '@specs/blog_v2.yaml', + 'generateUrls' => false, + 'generateControllers' => false, + 'generateModels' => true, + 'generateMigrations' => true, + 'excludeModels' => [ + 'Error', + ], +]; \ No newline at end of file diff --git a/tests/specs/blog_v2.yaml b/tests/specs/blog_v2.yaml new file mode 100644 index 00000000..b5dfbf4c --- /dev/null +++ b/tests/specs/blog_v2.yaml @@ -0,0 +1,244 @@ +openapi: "3.0.0" +info: + version: 2.0.0 + title: Blog prototype for test migrations. Modify columns, remove log_records table, added tags, post_tag tables + license: + name: MIT +servers: + - url: http://blog.dummy.io/v2 +paths: + /posts: + get: + summary: List all posts + operationId: listPosts + tags: + - posts + parameters: + - name: limit + in: query + description: How many items to return at one time (max 100) + required: false + schema: + type: integer + format: int32 + responses: + '200': + description: A paged array of posts + headers: + x-next: + description: A link to the next page of responses + schema: + type: string + content: + application/json: + schema: + $ref: "#/components/schemas/Posts" + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" +components: + schemas: + User: + description: The User + x-table: v2_users + required: + - id + - login + - email + - password + properties: + id: + type: integer + format: int64 + readOnly: True + login: + type: string + x-db-unique: 1 + email: + type: string + x-db-unique: 1 + password: + type: string + format: password + role: + type: string + x-db-type: enum + enum: + - admin + - editor + - reader + x-faker: "$faker->randomElement(['admin', 'editor', 'reader'])" + created_at: + type: string + format: date-time + Users: + type: array + items: + $ref: "#/components/schemas/User" + Category: + x-table: v2_categories + description: Category of posts + required: + - id + - title + - cover + - active + properties: + id: + type: integer + format: int64 + readOnly: True + title: + type: string + x-db-unique: true + maxLength: 100 + cover: + type: string + active: + type: boolean + posts: + type: array + items: + $ref: "#/components/schemas/Post" + Categories: + type: array + items: + $ref: "#/components/schemas/Category" + Post: + x-table: v2_posts + description: A blog post (uid used as pk for test purposes) + required: + - id + - title + - category + - author + - active + properties: + id: + type: integer + format: int64 + readOnly: True + title: + type: string + x-db-type: VARCHAR + x-db-unique: true + maxLength: 255 + slug: + type: string + minLength: 1 + maxLength: 200 + lang: + type: string + x-db-type: enum + enum: + - ru + - eng + default: ru + category: + $ref: "#/components/schemas/Category" + active: + type: boolean + created_at: + type: string + format: date + created_by: + $ref: "#/components/schemas/User" + comments: + type: array + items: + $ref: "#/components/schemas/Comment" + post_tags: + type: array + items: + $ref: "#/components/schemas/PostTag" + Posts: + type: array + items: + $ref: "#/components/schemas/Post" + Comment: + x-table: v2_comments + required: + - id + - post + - message + - created_at + properties: + id: + type: integer + format: int64 + readOnly: True + post: + $ref: "#/components/schemas/Post" + user: + $ref: "#/components/schemas/User" + message: + type: string + created_at: + type: string + format: date-time + Comments: + type: array + items: + $ref: "#/components/schemas/Comment" + Tag: + x-table: v2_tags + required: + - id + - name + - lang + properties: + id: + type: integer + format: int64 + readOnly: True + name: + type: string + x-db-type: VARCHAR + x-db-unique: true + maxLength: 100 + lang: + type: string + x-db-type: enum + enum: + - ru + - eng + post_tags: + type: array + items: + $ref: "#/components/schemas/PostTag" + Tags: + type: array + items: + $ref: "#/components/schemas/Tag" + PostTag: + x-table: v2_post_tag + required: + - id + - post + - tag + properties: + id: + type: integer + format: int64 + readOnly: True + post: + $ref: "#/components/schemas/Post" + tag: + $ref: "#/components/schemas/Tag" + PostTags: + type: array + items: + $ref: "#/components/schemas/PostTag" + Error: + required: + - code + - message + properties: + code: + type: integer + format: int32 + message: + type: string diff --git a/tests/specs/blog_v2/migrations/m200000_000000_create_table_v2_categories.php b/tests/specs/blog_v2/migrations/m200000_000000_create_table_v2_categories.php new file mode 100644 index 00000000..6ed093bb --- /dev/null +++ b/tests/specs/blog_v2/migrations/m200000_000000_create_table_v2_categories.php @@ -0,0 +1,22 @@ +createTable('{{%v2_categories}}', [ + 'id' => $this->bigPrimaryKey(), + 'title' => $this->string(100)->notNull()->unique(), + 'cover' => $this->text()->notNull(), + 'active' => $this->boolean()->notNull(), + ]); + } + + public function down() + { + $this->dropTable('{{%v2_categories}}'); + } +} diff --git a/tests/specs/blog_v2/migrations/m200000_000001_create_table_v2_users.php b/tests/specs/blog_v2/migrations/m200000_000001_create_table_v2_users.php new file mode 100644 index 00000000..2c033da0 --- /dev/null +++ b/tests/specs/blog_v2/migrations/m200000_000001_create_table_v2_users.php @@ -0,0 +1,24 @@ +createTable('{{%v2_users}}', [ + 'id' => $this->bigPrimaryKey(), + 'login' => $this->text()->notNull()->unique(), + 'email' => $this->text()->notNull()->unique(), + 'password' => $this->string()->notNull(), + 'role' => 'enum(\'admin\', \'editor\', \'reader\') NULL DEFAULT NULL', + 'created_at' => $this->timestamp()->null()->defaultValue(null), + ]); + } + + public function down() + { + $this->dropTable('{{%v2_users}}'); + } +} diff --git a/tests/specs/blog_v2/migrations/m200000_000002_create_table_v2_posts.php b/tests/specs/blog_v2/migrations/m200000_000002_create_table_v2_posts.php new file mode 100644 index 00000000..bc7c00a8 --- /dev/null +++ b/tests/specs/blog_v2/migrations/m200000_000002_create_table_v2_posts.php @@ -0,0 +1,30 @@ +createTable('{{%v2_posts}}', [ + 'id' => $this->bigPrimaryKey(), + 'title' => $this->string(255)->notNull()->unique(), + 'slug' => $this->string(200)->null()->defaultValue(null), + 'lang' => 'enum(\'ru\', \'eng\') NULL DEFAULT \'ru\'', + 'category_id' => $this->bigInteger()->notNull(), + 'active' => $this->boolean()->notNull(), + 'created_at' => $this->date()->null()->defaultValue(null), + 'created_by_id' => $this->bigInteger()->null()->defaultValue(null), + ]); + $this->addForeignKey('fk_v2_posts_category_id_v2_categories_id', '{{%v2_posts}}', 'category_id', '{{%v2_categories}}', 'id'); + $this->addForeignKey('fk_v2_posts_created_by_id_v2_users_id', '{{%v2_posts}}', 'created_by_id', '{{%v2_users}}', 'id'); + } + + public function down() + { + $this->dropForeignKey('fk_v2_posts_created_by_id_v2_users_id', '{{%v2_posts}}'); + $this->dropForeignKey('fk_v2_posts_category_id_v2_categories_id', '{{%v2_posts}}'); + $this->dropTable('{{%v2_posts}}'); + } +} diff --git a/tests/specs/blog_v2/migrations/m200000_000003_create_table_v2_comments.php b/tests/specs/blog_v2/migrations/m200000_000003_create_table_v2_comments.php new file mode 100644 index 00000000..53ab3ea5 --- /dev/null +++ b/tests/specs/blog_v2/migrations/m200000_000003_create_table_v2_comments.php @@ -0,0 +1,27 @@ +createTable('{{%v2_comments}}', [ + 'id' => $this->bigPrimaryKey(), + 'post_id' => $this->bigInteger()->notNull(), + 'user_id' => $this->bigInteger()->null()->defaultValue(null), + 'message' => $this->text()->notNull(), + 'created_at' => $this->timestamp()->notNull(), + ]); + $this->addForeignKey('fk_v2_comments_post_id_v2_posts_id', '{{%v2_comments}}', 'post_id', '{{%v2_posts}}', 'id'); + $this->addForeignKey('fk_v2_comments_user_id_v2_users_id', '{{%v2_comments}}', 'user_id', '{{%v2_users}}', 'id'); + } + + public function down() + { + $this->dropForeignKey('fk_v2_comments_user_id_v2_users_id', '{{%v2_comments}}'); + $this->dropForeignKey('fk_v2_comments_post_id_v2_posts_id', '{{%v2_comments}}'); + $this->dropTable('{{%v2_comments}}'); + } +} diff --git a/tests/specs/blog_v2/migrations/m200000_000004_create_table_v2_tags.php b/tests/specs/blog_v2/migrations/m200000_000004_create_table_v2_tags.php new file mode 100644 index 00000000..e7553664 --- /dev/null +++ b/tests/specs/blog_v2/migrations/m200000_000004_create_table_v2_tags.php @@ -0,0 +1,21 @@ +createTable('{{%v2_tags}}', [ + 'id' => $this->bigPrimaryKey(), + 'name' => $this->string(100)->notNull()->unique(), + 'lang' => 'enum(\'ru\', \'eng\') NOT NULL', + ]); + } + + public function down() + { + $this->dropTable('{{%v2_tags}}'); + } +} diff --git a/tests/specs/blog_v2/migrations/m200000_000005_create_table_v2_post_tag.php b/tests/specs/blog_v2/migrations/m200000_000005_create_table_v2_post_tag.php new file mode 100644 index 00000000..c3232acb --- /dev/null +++ b/tests/specs/blog_v2/migrations/m200000_000005_create_table_v2_post_tag.php @@ -0,0 +1,25 @@ +createTable('{{%v2_post_tag}}', [ + 'id' => $this->bigPrimaryKey(), + 'post_id' => $this->bigInteger()->notNull(), + 'tag_id' => $this->bigInteger()->notNull(), + ]); + $this->addForeignKey('fk_v2_post_tag_post_id_v2_posts_id', '{{%v2_post_tag}}', 'post_id', '{{%v2_posts}}', 'id'); + $this->addForeignKey('fk_v2_post_tag_tag_id_v2_tags_id', '{{%v2_post_tag}}', 'tag_id', '{{%v2_tags}}', 'id'); + } + + public function down() + { + $this->dropForeignKey('fk_v2_post_tag_tag_id_v2_tags_id', '{{%v2_post_tag}}'); + $this->dropForeignKey('fk_v2_post_tag_post_id_v2_posts_id', '{{%v2_post_tag}}'); + $this->dropTable('{{%v2_post_tag}}'); + } +} diff --git a/tests/specs/blog_v2/migrations_maria_db/m200000_000000_change_table_v2_categories.php b/tests/specs/blog_v2/migrations_maria_db/m200000_000000_change_table_v2_categories.php new file mode 100644 index 00000000..503eddd0 --- /dev/null +++ b/tests/specs/blog_v2/migrations_maria_db/m200000_000000_change_table_v2_categories.php @@ -0,0 +1,23 @@ +addColumn('{{%v2_categories}}', 'cover', $this->text()->notNull()); + $this->alterColumn('{{%v2_categories}}', 'active', $this->boolean()->notNull()); + $this->createIndex('unique_title', '{{%v2_categories}}', 'title', true); + $this->alterColumn('{{%v2_categories}}', 'title', $this->string(100)->notNull()); + } + + public function down() + { + $this->alterColumn('{{%v2_categories}}', 'title', $this->string(255)->notNull()); + $this->dropIndex('unique_title', '{{%v2_categories}}'); + $this->alterColumn('{{%v2_categories}}', 'active', $this->tinyInteger(1)->notNull()->defaultValue(0)); + $this->dropColumn('{{%v2_categories}}', 'cover'); + } +} diff --git a/tests/specs/blog_v2/migrations_maria_db/m200000_000001_change_table_v2_users.php b/tests/specs/blog_v2/migrations_maria_db/m200000_000001_change_table_v2_users.php new file mode 100644 index 00000000..56e3a98b --- /dev/null +++ b/tests/specs/blog_v2/migrations_maria_db/m200000_000001_change_table_v2_users.php @@ -0,0 +1,29 @@ +addColumn('{{%v2_users}}', 'login', $this->text()->notNull()->unique()); + $this->dropColumn('{{%v2_users}}', 'username'); + $this->alterColumn('{{%v2_users}}', 'created_at', $this->timestamp()->null()->defaultValue(null)); + $this->createIndex('unique_email', '{{%v2_users}}', 'email', true); + $this->alterColumn('{{%v2_users}}', 'email', $this->text()->notNull()); + $this->alterColumn('{{%v2_users}}', 'password', $this->string()->notNull()); + $this->alterColumn('{{%v2_users}}', 'role', "enum('admin', 'editor', 'reader') NULL DEFAULT NULL"); + } + + public function down() + { + $this->alterColumn('{{%v2_users}}', 'role', $this->string(20)->null()->defaultValue("reader")); + $this->alterColumn('{{%v2_users}}', 'password', $this->string(255)->notNull()); + $this->alterColumn('{{%v2_users}}', 'email', $this->string(200)->notNull()); + $this->dropIndex('unique_email', '{{%v2_users}}'); + $this->alterColumn('{{%v2_users}}', 'created_at', $this->timestamp()->null()->defaultExpression("CURRENT_TIMESTAMP")); + $this->addColumn('{{%v2_users}}', 'username', $this->string(200)->notNull()); + $this->dropColumn('{{%v2_users}}', 'login'); + } +} diff --git a/tests/specs/blog_v2/migrations_maria_db/m200000_000002_change_table_v2_posts.php b/tests/specs/blog_v2/migrations_maria_db/m200000_000002_change_table_v2_posts.php new file mode 100644 index 00000000..e43d2e85 --- /dev/null +++ b/tests/specs/blog_v2/migrations_maria_db/m200000_000002_change_table_v2_posts.php @@ -0,0 +1,33 @@ +addColumn('{{%v2_posts}}', 'id', $this->bigPrimaryKey()); + $this->addColumn('{{%v2_posts}}', 'lang', "enum('ru', 'eng') NULL DEFAULT 'ru'"); + $this->dropColumn('{{%v2_posts}}', 'uid'); + $this->alterColumn('{{%v2_posts}}', 'active', $this->boolean()->notNull()); + $this->alterColumn('{{%v2_posts}}', 'category_id', $this->bigInteger()->notNull()); + $this->alterColumn('{{%v2_posts}}', 'created_by_id', $this->bigInteger()->null()->defaultValue(null)); + $this->createIndex('unique_title', '{{%v2_posts}}', 'title', true); + $this->addForeignKey('fk_v2_posts_category_id_v2_categories_id', '{{%v2_posts}}', 'category_id', '{{%v2_categories}}', 'id'); + $this->addForeignKey('fk_v2_posts_created_by_id_v2_users_id', '{{%v2_posts}}', 'created_by_id', '{{%v2_users}}', 'id'); + } + + public function down() + { + $this->dropForeignKey('fk_v2_posts_created_by_id_v2_users_id', '{{%v2_posts}}'); + $this->dropForeignKey('fk_v2_posts_category_id_v2_categories_id', '{{%v2_posts}}'); + $this->dropIndex('unique_title', '{{%v2_posts}}'); + $this->alterColumn('{{%v2_posts}}', 'created_by_id', $this->integer(11)->null()->defaultValue(null)); + $this->alterColumn('{{%v2_posts}}', 'category_id', $this->integer(11)->notNull()); + $this->alterColumn('{{%v2_posts}}', 'active', $this->tinyInteger(1)->notNull()->defaultValue(0)); + $this->addColumn('{{%v2_posts}}', 'uid', $this->bigInteger(20)->notNull()); + $this->dropColumn('{{%v2_posts}}', 'lang'); + $this->dropColumn('{{%v2_posts}}', 'id'); + } +} diff --git a/tests/specs/blog_v2/migrations_maria_db/m200000_000003_change_table_v2_comments.php b/tests/specs/blog_v2/migrations_maria_db/m200000_000003_change_table_v2_comments.php new file mode 100644 index 00000000..665208af --- /dev/null +++ b/tests/specs/blog_v2/migrations_maria_db/m200000_000003_change_table_v2_comments.php @@ -0,0 +1,29 @@ +addColumn('{{%v2_comments}}', 'user_id', $this->bigInteger()->null()->defaultValue(null)); + $this->dropColumn('{{%v2_comments}}', 'author_id'); + $this->alterColumn('{{%v2_comments}}', 'created_at', $this->timestamp()->notNull()); + $this->alterColumn('{{%v2_comments}}', 'message', $this->text()->notNull()); + $this->alterColumn('{{%v2_comments}}', 'post_id', $this->bigInteger()->notNull()); + $this->addForeignKey('fk_v2_comments_post_id_v2_posts_id', '{{%v2_comments}}', 'post_id', '{{%v2_posts}}', 'id'); + $this->addForeignKey('fk_v2_comments_user_id_v2_users_id', '{{%v2_comments}}', 'user_id', '{{%v2_users}}', 'id'); + } + + public function down() + { + $this->dropForeignKey('fk_v2_comments_user_id_v2_users_id', '{{%v2_comments}}'); + $this->dropForeignKey('fk_v2_comments_post_id_v2_posts_id', '{{%v2_comments}}'); + $this->alterColumn('{{%v2_comments}}', 'post_id', $this->bigInteger(20)->notNull()); + $this->alterColumn('{{%v2_comments}}', 'message', $this->text()->notNull()->defaultValue("\'{}\'")); + $this->alterColumn('{{%v2_comments}}', 'created_at', $this->integer(11)->notNull()); + $this->addColumn('{{%v2_comments}}', 'author_id', $this->integer(11)->notNull()); + $this->dropColumn('{{%v2_comments}}', 'user_id'); + } +} diff --git a/tests/specs/blog_v2/migrations_maria_db/m200000_000004_create_table_v2_tags.php b/tests/specs/blog_v2/migrations_maria_db/m200000_000004_create_table_v2_tags.php new file mode 100644 index 00000000..e7553664 --- /dev/null +++ b/tests/specs/blog_v2/migrations_maria_db/m200000_000004_create_table_v2_tags.php @@ -0,0 +1,21 @@ +createTable('{{%v2_tags}}', [ + 'id' => $this->bigPrimaryKey(), + 'name' => $this->string(100)->notNull()->unique(), + 'lang' => 'enum(\'ru\', \'eng\') NOT NULL', + ]); + } + + public function down() + { + $this->dropTable('{{%v2_tags}}'); + } +} diff --git a/tests/specs/blog_v2/migrations_maria_db/m200000_000005_create_table_v2_post_tag.php b/tests/specs/blog_v2/migrations_maria_db/m200000_000005_create_table_v2_post_tag.php new file mode 100644 index 00000000..c3232acb --- /dev/null +++ b/tests/specs/blog_v2/migrations_maria_db/m200000_000005_create_table_v2_post_tag.php @@ -0,0 +1,25 @@ +createTable('{{%v2_post_tag}}', [ + 'id' => $this->bigPrimaryKey(), + 'post_id' => $this->bigInteger()->notNull(), + 'tag_id' => $this->bigInteger()->notNull(), + ]); + $this->addForeignKey('fk_v2_post_tag_post_id_v2_posts_id', '{{%v2_post_tag}}', 'post_id', '{{%v2_posts}}', 'id'); + $this->addForeignKey('fk_v2_post_tag_tag_id_v2_tags_id', '{{%v2_post_tag}}', 'tag_id', '{{%v2_tags}}', 'id'); + } + + public function down() + { + $this->dropForeignKey('fk_v2_post_tag_tag_id_v2_tags_id', '{{%v2_post_tag}}'); + $this->dropForeignKey('fk_v2_post_tag_post_id_v2_posts_id', '{{%v2_post_tag}}'); + $this->dropTable('{{%v2_post_tag}}'); + } +} diff --git a/tests/specs/blog_v2/migrations_mysql_db/m200000_000000_change_table_v2_categories.php b/tests/specs/blog_v2/migrations_mysql_db/m200000_000000_change_table_v2_categories.php new file mode 100644 index 00000000..503eddd0 --- /dev/null +++ b/tests/specs/blog_v2/migrations_mysql_db/m200000_000000_change_table_v2_categories.php @@ -0,0 +1,23 @@ +addColumn('{{%v2_categories}}', 'cover', $this->text()->notNull()); + $this->alterColumn('{{%v2_categories}}', 'active', $this->boolean()->notNull()); + $this->createIndex('unique_title', '{{%v2_categories}}', 'title', true); + $this->alterColumn('{{%v2_categories}}', 'title', $this->string(100)->notNull()); + } + + public function down() + { + $this->alterColumn('{{%v2_categories}}', 'title', $this->string(255)->notNull()); + $this->dropIndex('unique_title', '{{%v2_categories}}'); + $this->alterColumn('{{%v2_categories}}', 'active', $this->tinyInteger(1)->notNull()->defaultValue(0)); + $this->dropColumn('{{%v2_categories}}', 'cover'); + } +} diff --git a/tests/specs/blog_v2/migrations_mysql_db/m200000_000001_change_table_v2_users.php b/tests/specs/blog_v2/migrations_mysql_db/m200000_000001_change_table_v2_users.php new file mode 100644 index 00000000..56e3a98b --- /dev/null +++ b/tests/specs/blog_v2/migrations_mysql_db/m200000_000001_change_table_v2_users.php @@ -0,0 +1,29 @@ +addColumn('{{%v2_users}}', 'login', $this->text()->notNull()->unique()); + $this->dropColumn('{{%v2_users}}', 'username'); + $this->alterColumn('{{%v2_users}}', 'created_at', $this->timestamp()->null()->defaultValue(null)); + $this->createIndex('unique_email', '{{%v2_users}}', 'email', true); + $this->alterColumn('{{%v2_users}}', 'email', $this->text()->notNull()); + $this->alterColumn('{{%v2_users}}', 'password', $this->string()->notNull()); + $this->alterColumn('{{%v2_users}}', 'role', "enum('admin', 'editor', 'reader') NULL DEFAULT NULL"); + } + + public function down() + { + $this->alterColumn('{{%v2_users}}', 'role', $this->string(20)->null()->defaultValue("reader")); + $this->alterColumn('{{%v2_users}}', 'password', $this->string(255)->notNull()); + $this->alterColumn('{{%v2_users}}', 'email', $this->string(200)->notNull()); + $this->dropIndex('unique_email', '{{%v2_users}}'); + $this->alterColumn('{{%v2_users}}', 'created_at', $this->timestamp()->null()->defaultExpression("CURRENT_TIMESTAMP")); + $this->addColumn('{{%v2_users}}', 'username', $this->string(200)->notNull()); + $this->dropColumn('{{%v2_users}}', 'login'); + } +} diff --git a/tests/specs/blog_v2/migrations_mysql_db/m200000_000002_change_table_v2_posts.php b/tests/specs/blog_v2/migrations_mysql_db/m200000_000002_change_table_v2_posts.php new file mode 100644 index 00000000..e43d2e85 --- /dev/null +++ b/tests/specs/blog_v2/migrations_mysql_db/m200000_000002_change_table_v2_posts.php @@ -0,0 +1,33 @@ +addColumn('{{%v2_posts}}', 'id', $this->bigPrimaryKey()); + $this->addColumn('{{%v2_posts}}', 'lang', "enum('ru', 'eng') NULL DEFAULT 'ru'"); + $this->dropColumn('{{%v2_posts}}', 'uid'); + $this->alterColumn('{{%v2_posts}}', 'active', $this->boolean()->notNull()); + $this->alterColumn('{{%v2_posts}}', 'category_id', $this->bigInteger()->notNull()); + $this->alterColumn('{{%v2_posts}}', 'created_by_id', $this->bigInteger()->null()->defaultValue(null)); + $this->createIndex('unique_title', '{{%v2_posts}}', 'title', true); + $this->addForeignKey('fk_v2_posts_category_id_v2_categories_id', '{{%v2_posts}}', 'category_id', '{{%v2_categories}}', 'id'); + $this->addForeignKey('fk_v2_posts_created_by_id_v2_users_id', '{{%v2_posts}}', 'created_by_id', '{{%v2_users}}', 'id'); + } + + public function down() + { + $this->dropForeignKey('fk_v2_posts_created_by_id_v2_users_id', '{{%v2_posts}}'); + $this->dropForeignKey('fk_v2_posts_category_id_v2_categories_id', '{{%v2_posts}}'); + $this->dropIndex('unique_title', '{{%v2_posts}}'); + $this->alterColumn('{{%v2_posts}}', 'created_by_id', $this->integer(11)->null()->defaultValue(null)); + $this->alterColumn('{{%v2_posts}}', 'category_id', $this->integer(11)->notNull()); + $this->alterColumn('{{%v2_posts}}', 'active', $this->tinyInteger(1)->notNull()->defaultValue(0)); + $this->addColumn('{{%v2_posts}}', 'uid', $this->bigInteger(20)->notNull()); + $this->dropColumn('{{%v2_posts}}', 'lang'); + $this->dropColumn('{{%v2_posts}}', 'id'); + } +} diff --git a/tests/specs/blog_v2/migrations_mysql_db/m200000_000003_change_table_v2_comments.php b/tests/specs/blog_v2/migrations_mysql_db/m200000_000003_change_table_v2_comments.php new file mode 100644 index 00000000..d7ea9cf9 --- /dev/null +++ b/tests/specs/blog_v2/migrations_mysql_db/m200000_000003_change_table_v2_comments.php @@ -0,0 +1,29 @@ +addColumn('{{%v2_comments}}', 'user_id', $this->bigInteger()->null()->defaultValue(null)); + $this->dropColumn('{{%v2_comments}}', 'author_id'); + $this->alterColumn('{{%v2_comments}}', 'created_at', $this->timestamp()->notNull()); + $this->alterColumn('{{%v2_comments}}', 'message', $this->text()->notNull()); + $this->alterColumn('{{%v2_comments}}', 'post_id', $this->bigInteger()->notNull()); + $this->addForeignKey('fk_v2_comments_post_id_v2_posts_id', '{{%v2_comments}}', 'post_id', '{{%v2_posts}}', 'id'); + $this->addForeignKey('fk_v2_comments_user_id_v2_users_id', '{{%v2_comments}}', 'user_id', '{{%v2_users}}', 'id'); + } + + public function down() + { + $this->dropForeignKey('fk_v2_comments_user_id_v2_users_id', '{{%v2_comments}}'); + $this->dropForeignKey('fk_v2_comments_post_id_v2_posts_id', '{{%v2_comments}}'); + $this->alterColumn('{{%v2_comments}}', 'post_id', $this->bigInteger(20)->notNull()); + $this->alterColumn('{{%v2_comments}}', 'message', "json NOT NULL"); + $this->alterColumn('{{%v2_comments}}', 'created_at', $this->integer(11)->notNull()); + $this->addColumn('{{%v2_comments}}', 'author_id', $this->integer(11)->notNull()); + $this->dropColumn('{{%v2_comments}}', 'user_id'); + } +} diff --git a/tests/specs/blog_v2/migrations_mysql_db/m200000_000004_create_table_v2_tags.php b/tests/specs/blog_v2/migrations_mysql_db/m200000_000004_create_table_v2_tags.php new file mode 100644 index 00000000..e7553664 --- /dev/null +++ b/tests/specs/blog_v2/migrations_mysql_db/m200000_000004_create_table_v2_tags.php @@ -0,0 +1,21 @@ +createTable('{{%v2_tags}}', [ + 'id' => $this->bigPrimaryKey(), + 'name' => $this->string(100)->notNull()->unique(), + 'lang' => 'enum(\'ru\', \'eng\') NOT NULL', + ]); + } + + public function down() + { + $this->dropTable('{{%v2_tags}}'); + } +} diff --git a/tests/specs/blog_v2/migrations_mysql_db/m200000_000005_create_table_v2_post_tag.php b/tests/specs/blog_v2/migrations_mysql_db/m200000_000005_create_table_v2_post_tag.php new file mode 100644 index 00000000..c3232acb --- /dev/null +++ b/tests/specs/blog_v2/migrations_mysql_db/m200000_000005_create_table_v2_post_tag.php @@ -0,0 +1,25 @@ +createTable('{{%v2_post_tag}}', [ + 'id' => $this->bigPrimaryKey(), + 'post_id' => $this->bigInteger()->notNull(), + 'tag_id' => $this->bigInteger()->notNull(), + ]); + $this->addForeignKey('fk_v2_post_tag_post_id_v2_posts_id', '{{%v2_post_tag}}', 'post_id', '{{%v2_posts}}', 'id'); + $this->addForeignKey('fk_v2_post_tag_tag_id_v2_tags_id', '{{%v2_post_tag}}', 'tag_id', '{{%v2_tags}}', 'id'); + } + + public function down() + { + $this->dropForeignKey('fk_v2_post_tag_tag_id_v2_tags_id', '{{%v2_post_tag}}'); + $this->dropForeignKey('fk_v2_post_tag_post_id_v2_posts_id', '{{%v2_post_tag}}'); + $this->dropTable('{{%v2_post_tag}}'); + } +} diff --git a/tests/specs/blog_v2/migrations_pgsql_db/m200000_000000_change_table_v2_categories.php b/tests/specs/blog_v2/migrations_pgsql_db/m200000_000000_change_table_v2_categories.php new file mode 100644 index 00000000..c3c7add0 --- /dev/null +++ b/tests/specs/blog_v2/migrations_pgsql_db/m200000_000000_change_table_v2_categories.php @@ -0,0 +1,23 @@ +addColumn('{{%v2_categories}}', 'cover', $this->text()->notNull()); + $this->alterColumn('{{%v2_categories}}', 'active', "DROP DEFAULT"); + $this->createIndex('unique_title', '{{%v2_categories}}', 'title', true); + $this->alterColumn('{{%v2_categories}}', 'title', $this->string(100)); + } + + public function safeDown() + { + $this->dropIndex('unique_title', '{{%v2_categories}}'); + $this->dropColumn('{{%v2_categories}}', 'cover'); + $this->alterColumn('{{%v2_categories}}', 'active', "SET DEFAULT FALSE"); + $this->alterColumn('{{%v2_categories}}', 'title', $this->string(255)); + } +} diff --git a/tests/specs/blog_v2/migrations_pgsql_db/m200000_000001_change_table_v2_users.php b/tests/specs/blog_v2/migrations_pgsql_db/m200000_000001_change_table_v2_users.php new file mode 100644 index 00000000..8e4dd383 --- /dev/null +++ b/tests/specs/blog_v2/migrations_pgsql_db/m200000_000001_change_table_v2_users.php @@ -0,0 +1,33 @@ +execute('CREATE TYPE enum_role AS ENUM(\'admin\',\'editor\',\'reader\')'); + $this->addColumn('{{%v2_users}}', 'login', $this->text()->notNull()->unique()); + $this->dropColumn('{{%v2_users}}', 'username'); + $this->alterColumn('{{%v2_users}}', 'created_at', "DROP DEFAULT"); + $this->createIndex('unique_email', '{{%v2_users}}', 'email', true); + $this->alterColumn('{{%v2_users}}', 'email', $this->text()); + $this->alterColumn('{{%v2_users}}', 'password', $this->string()); + $this->alterColumn('{{%v2_users}}', 'role', "enum_role USING role::enum_role"); + $this->alterColumn('{{%v2_users}}', 'role', "DROP DEFAULT"); + } + + public function safeDown() + { + $this->dropIndex('unique_email', '{{%v2_users}}'); + $this->addColumn('{{%v2_users}}', 'username', $this->string(200)->notNull()); + $this->dropColumn('{{%v2_users}}', 'login'); + $this->alterColumn('{{%v2_users}}', 'created_at', "SET DEFAULT 'CURRENT_TIMESTAMP'"); + $this->alterColumn('{{%v2_users}}', 'email', $this->string(200)); + $this->alterColumn('{{%v2_users}}', 'password', $this->string(255)); + $this->alterColumn('{{%v2_users}}', 'role', $this->string(20)); + $this->alterColumn('{{%v2_users}}', 'role', "SET DEFAULT 'reader'"); + $this->execute('DROP TYPE enum_role'); + } +} diff --git a/tests/specs/blog_v2/migrations_pgsql_db/m200000_000002_change_table_v2_posts.php b/tests/specs/blog_v2/migrations_pgsql_db/m200000_000002_change_table_v2_posts.php new file mode 100644 index 00000000..26289c06 --- /dev/null +++ b/tests/specs/blog_v2/migrations_pgsql_db/m200000_000002_change_table_v2_posts.php @@ -0,0 +1,35 @@ +execute('CREATE TYPE enum_lang AS ENUM(\'ru\',\'eng\')'); + $this->addColumn('{{%v2_posts}}', 'id', $this->bigPrimaryKey()); + $this->addColumn('{{%v2_posts}}', 'lang', "enum_lang NULL DEFAULT 'ru'"); + $this->dropColumn('{{%v2_posts}}', 'uid'); + $this->alterColumn('{{%v2_posts}}', 'active', "DROP DEFAULT"); + $this->alterColumn('{{%v2_posts}}', 'category_id', $this->bigInteger()); + $this->alterColumn('{{%v2_posts}}', 'created_by_id', $this->bigInteger()); + $this->createIndex('unique_title', '{{%v2_posts}}', 'title', true); + $this->addForeignKey('fk_v2_posts_category_id_v2_categories_id', '{{%v2_posts}}', 'category_id', '{{%v2_categories}}', 'id'); + $this->addForeignKey('fk_v2_posts_created_by_id_v2_users_id', '{{%v2_posts}}', 'created_by_id', '{{%v2_users}}', 'id'); + } + + public function safeDown() + { + $this->dropForeignKey('fk_v2_posts_created_by_id_v2_users_id', '{{%v2_posts}}'); + $this->dropForeignKey('fk_v2_posts_category_id_v2_categories_id', '{{%v2_posts}}'); + $this->dropIndex('unique_title', '{{%v2_posts}}'); + $this->addColumn('{{%v2_posts}}', 'uid', $this->bigInteger()->notNull()); + $this->execute('DROP TYPE enum_lang'); + $this->dropColumn('{{%v2_posts}}', 'lang'); + $this->dropColumn('{{%v2_posts}}', 'id'); + $this->alterColumn('{{%v2_posts}}', 'active', "SET DEFAULT FALSE"); + $this->alterColumn('{{%v2_posts}}', 'category_id', $this->integer()); + $this->alterColumn('{{%v2_posts}}', 'created_by_id', $this->integer()); + } +} diff --git a/tests/specs/blog_v2/migrations_pgsql_db/m200000_000003_change_table_v2_comments.php b/tests/specs/blog_v2/migrations_pgsql_db/m200000_000003_change_table_v2_comments.php new file mode 100644 index 00000000..2af6194e --- /dev/null +++ b/tests/specs/blog_v2/migrations_pgsql_db/m200000_000003_change_table_v2_comments.php @@ -0,0 +1,29 @@ +addColumn('{{%v2_comments}}', 'user_id', $this->bigInteger()->null()->defaultValue(null)); + $this->dropColumn('{{%v2_comments}}', 'author_id'); + $this->alterColumn('{{%v2_comments}}', 'created_at', $this->timestamp()); + $this->alterColumn('{{%v2_comments}}', 'message', $this->text()); + $this->alterColumn('{{%v2_comments}}', 'message', "DROP DEFAULT"); + $this->addForeignKey('fk_v2_comments_post_id_v2_posts_id', '{{%v2_comments}}', 'post_id', '{{%v2_posts}}', 'id'); + $this->addForeignKey('fk_v2_comments_user_id_v2_users_id', '{{%v2_comments}}', 'user_id', '{{%v2_users}}', 'id'); + } + + public function safeDown() + { + $this->dropForeignKey('fk_v2_comments_user_id_v2_users_id', '{{%v2_comments}}'); + $this->dropForeignKey('fk_v2_comments_post_id_v2_posts_id', '{{%v2_comments}}'); + $this->addColumn('{{%v2_comments}}', 'author_id', $this->integer()->notNull()); + $this->dropColumn('{{%v2_comments}}', 'user_id'); + $this->alterColumn('{{%v2_comments}}', 'created_at', $this->integer()); + $this->alterColumn('{{%v2_comments}}', 'message', "jsonb"); + $this->alterColumn('{{%v2_comments}}', 'message', "SET DEFAULT '[]'"); + } +} diff --git a/tests/specs/blog_v2/migrations_pgsql_db/m200000_000004_create_table_v2_tags.php b/tests/specs/blog_v2/migrations_pgsql_db/m200000_000004_create_table_v2_tags.php new file mode 100644 index 00000000..1da20213 --- /dev/null +++ b/tests/specs/blog_v2/migrations_pgsql_db/m200000_000004_create_table_v2_tags.php @@ -0,0 +1,23 @@ +execute('CREATE TYPE enum_lang AS ENUM(\'ru\',\'eng\')'); + $this->createTable('{{%v2_tags}}', [ + 'id' => $this->bigPrimaryKey(), + 'name' => $this->string(100)->notNull()->unique(), + 'lang' => 'enum_lang NOT NULL', + ]); + } + + public function safeDown() + { + $this->execute('DROP TYPE enum_lang'); + $this->dropTable('{{%v2_tags}}'); + } +} diff --git a/tests/specs/blog_v2/migrations_pgsql_db/m200000_000005_create_table_v2_post_tag.php b/tests/specs/blog_v2/migrations_pgsql_db/m200000_000005_create_table_v2_post_tag.php new file mode 100644 index 00000000..816bba17 --- /dev/null +++ b/tests/specs/blog_v2/migrations_pgsql_db/m200000_000005_create_table_v2_post_tag.php @@ -0,0 +1,25 @@ +createTable('{{%v2_post_tag}}', [ + 'id' => $this->bigPrimaryKey(), + 'post_id' => $this->bigInteger()->notNull(), + 'tag_id' => $this->bigInteger()->notNull(), + ]); + $this->addForeignKey('fk_v2_post_tag_post_id_v2_posts_id', '{{%v2_post_tag}}', 'post_id', '{{%v2_posts}}', 'id'); + $this->addForeignKey('fk_v2_post_tag_tag_id_v2_tags_id', '{{%v2_post_tag}}', 'tag_id', '{{%v2_tags}}', 'id'); + } + + public function safeDown() + { + $this->dropForeignKey('fk_v2_post_tag_tag_id_v2_tags_id', '{{%v2_post_tag}}'); + $this->dropForeignKey('fk_v2_post_tag_post_id_v2_posts_id', '{{%v2_post_tag}}'); + $this->dropTable('{{%v2_post_tag}}'); + } +} diff --git a/tests/specs/blog_v2/models/Category.php b/tests/specs/blog_v2/models/Category.php new file mode 100644 index 00000000..6e9d01c6 --- /dev/null +++ b/tests/specs/blog_v2/models/Category.php @@ -0,0 +1,10 @@ +language); + $uniqueFaker = new UniqueGenerator($faker); + $model = new Category(); + $model->id = $uniqueFaker->numberBetween(0, 2147483647); + $model->title = substr($faker->sentence, 0, 100); + $model->cover = $faker->sentence; + $model->active = $faker->boolean; + return $model; + } +} diff --git a/tests/specs/blog_v2/models/Comment.php b/tests/specs/blog_v2/models/Comment.php new file mode 100644 index 00000000..7a4886f7 --- /dev/null +++ b/tests/specs/blog_v2/models/Comment.php @@ -0,0 +1,10 @@ +language); + $uniqueFaker = new UniqueGenerator($faker); + $model = new Comment(); + $model->id = $uniqueFaker->numberBetween(0, 2147483647); + $model->message = $faker->sentence; + $model->created_at = $faker->dateTimeThisCentury->format('Y-m-d H:i:s'); + return $model; + } +} diff --git a/tests/specs/blog_v2/models/Post.php b/tests/specs/blog_v2/models/Post.php new file mode 100644 index 00000000..2825fe31 --- /dev/null +++ b/tests/specs/blog_v2/models/Post.php @@ -0,0 +1,10 @@ +language); + $uniqueFaker = new UniqueGenerator($faker); + $model = new Post(); + $model->id = $uniqueFaker->numberBetween(0, 2147483647); + $model->title = substr($faker->sentence, 0, 255); + $model->slug = substr($uniqueFaker->slug, 0, 200); + $model->lang = $faker->randomElement(array ( + 0 => 'ru', + 1 => 'eng', +)); + $model->active = $faker->boolean; + $model->created_at = $faker->iso8601; + return $model; + } +} diff --git a/tests/specs/blog_v2/models/PostTag.php b/tests/specs/blog_v2/models/PostTag.php new file mode 100644 index 00000000..3538dcae --- /dev/null +++ b/tests/specs/blog_v2/models/PostTag.php @@ -0,0 +1,10 @@ +language); + $uniqueFaker = new UniqueGenerator($faker); + $model = new PostTag(); + $model->id = $uniqueFaker->numberBetween(0, 2147483647); + return $model; + } +} diff --git a/tests/specs/blog_v2/models/Tag.php b/tests/specs/blog_v2/models/Tag.php new file mode 100644 index 00000000..43327f05 --- /dev/null +++ b/tests/specs/blog_v2/models/Tag.php @@ -0,0 +1,10 @@ +language); + $uniqueFaker = new UniqueGenerator($faker); + $model = new Tag(); + $model->id = $uniqueFaker->numberBetween(0, 2147483647); + $model->name = substr($faker->text(100), 0, 100); + $model->lang = $faker->randomElement(array ( + 0 => 'ru', + 1 => 'eng', +)); + return $model; + } +} diff --git a/tests/specs/blog_v2/models/User.php b/tests/specs/blog_v2/models/User.php new file mode 100644 index 00000000..9b837d6e --- /dev/null +++ b/tests/specs/blog_v2/models/User.php @@ -0,0 +1,10 @@ +language); + $uniqueFaker = new UniqueGenerator($faker); + $model = new User(); + $model->id = $uniqueFaker->numberBetween(0, 2147483647); + $model->login = $faker->userName; + $model->email = $faker->safeEmail; + $model->password = $faker->password; + $model->role = $faker->randomElement(['admin', 'editor', 'reader']); + $model->created_at = $faker->dateTimeThisCentury->format('Y-m-d H:i:s'); + return $model; + } +} diff --git a/tests/specs/blog_v2/models/base/Category.php b/tests/specs/blog_v2/models/base/Category.php new file mode 100644 index 00000000..9612e7ff --- /dev/null +++ b/tests/specs/blog_v2/models/base/Category.php @@ -0,0 +1,36 @@ +hasMany(\app\models\Post::class,['category_id' => 'id']); + } +} diff --git a/tests/specs/blog_v2/models/base/Comment.php b/tests/specs/blog_v2/models/base/Comment.php new file mode 100644 index 00000000..355f9df8 --- /dev/null +++ b/tests/specs/blog_v2/models/base/Comment.php @@ -0,0 +1,44 @@ + 'Post'], + [['user_id'], 'exist', 'targetRelation' => 'User'], + [['message', 'created_at'], 'string'], + ]; + } + + public function getPost() + { + return $this->hasOne(\app\models\Post::class,['id' => 'post_id']); + } + public function getUser() + { + return $this->hasOne(\app\models\User::class,['id' => 'user_id']); + } +} diff --git a/tests/specs/blog_v2/models/base/Post.php b/tests/specs/blog_v2/models/base/Post.php new file mode 100644 index 00000000..2e9ea950 --- /dev/null +++ b/tests/specs/blog_v2/models/base/Post.php @@ -0,0 +1,58 @@ + 'Category'], + [['created_by_id'], 'exist', 'targetRelation' => 'CreatedBy'], + [['title', 'slug', 'lang', 'created_at'], 'string'], + [['active'], 'boolean'], + ]; + } + + public function getCategory() + { + return $this->hasOne(\app\models\Category::class,['id' => 'category_id']); + } + public function getCreatedBy() + { + return $this->hasOne(\app\models\User::class,['id' => 'created_by_id']); + } + public function getComments() + { + return $this->hasMany(\app\models\Comment::class,['post_id' => 'id']); + } + public function getPostTags() + { + return $this->hasMany(\app\models\PostTag::class,['post_id' => 'id']); + } +} diff --git a/tests/specs/blog_v2/models/base/PostTag.php b/tests/specs/blog_v2/models/base/PostTag.php new file mode 100644 index 00000000..1b47cc14 --- /dev/null +++ b/tests/specs/blog_v2/models/base/PostTag.php @@ -0,0 +1,40 @@ + 'Post'], + [['tag_id'], 'exist', 'targetRelation' => 'Tag'], + ]; + } + + public function getPost() + { + return $this->hasOne(\app\models\Post::class,['id' => 'post_id']); + } + public function getTag() + { + return $this->hasOne(\app\models\Tag::class,['id' => 'tag_id']); + } +} diff --git a/tests/specs/blog_v2/models/base/Tag.php b/tests/specs/blog_v2/models/base/Tag.php new file mode 100644 index 00000000..411b725a --- /dev/null +++ b/tests/specs/blog_v2/models/base/Tag.php @@ -0,0 +1,34 @@ +hasMany(\app\models\PostTag::class,['tag_id' => 'id']); + } +} diff --git a/tests/specs/blog_v2/models/base/User.php b/tests/specs/blog_v2/models/base/User.php new file mode 100644 index 00000000..e35e7328 --- /dev/null +++ b/tests/specs/blog_v2/models/base/User.php @@ -0,0 +1,32 @@ + '@specs/menu.yaml', + 'generateUrls' => false, + 'generateControllers' => false, + 'generateModels' => true, + 'generateMigrations' => true, + 'excludeModels' => [ + 'Error', + ], +]; \ No newline at end of file diff --git a/tests/specs/menu.yaml b/tests/specs/menu.yaml new file mode 100644 index 00000000..f209a591 --- /dev/null +++ b/tests/specs/menu.yaml @@ -0,0 +1,77 @@ +openapi: "3.0.0" +info: + version: 1.0.0 + title: Menu prototype for test migrations + license: + name: MIT +servers: + - url: http://menu.dummy.io/v1 +paths: + /: + get: + summary: List all + operationId: listAll + tags: + - all + responses: + '200': + description: A paged array of menu items + headers: + x-next: + description: A link to the next page of responses + schema: + type: string + content: + application/json: + schema: + $ref: "#/components/schemas/Menu" + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" +components: + schemas: + Menu: + required: + - id + - name + properties: + id: + type: integer + format: int64 + readOnly: True + name: + type: string + maxLength: 100 + minLength: 3 + parent: + $ref: '#/components/schemas/Menu/properties/id' + childes: + type: array + items: + $ref: '#/components/schemas/Menu/properties/parent' + args: + type: array + x-db-type: text[] + default: + - foo + - bar + - baz + kwargs: + type: string + x-db-type: json + default: + - foo: bar + - buzz: fizz + Error: + required: + - code + - message + properties: + code: + type: integer + format: int32 + message: + type: string diff --git a/tests/specs/menu/migrations/m200000_000000_create_table_menus.php b/tests/specs/menu/migrations/m200000_000000_create_table_menus.php new file mode 100644 index 00000000..66d43585 --- /dev/null +++ b/tests/specs/menu/migrations/m200000_000000_create_table_menus.php @@ -0,0 +1,25 @@ +createTable('{{%menus}}', [ + 'id' => $this->bigPrimaryKey(), + 'name' => $this->string(100)->notNull(), + 'parent_id' => $this->bigInteger()->null()->defaultValue(null), + 'args' => 'text[] NULL DEFAULT \'{"foo","bar","baz"}\'', + 'kwargs' => 'json NOT NULL DEFAULT \'[{"foo":"bar"},{"buzz":"fizz"}]\'', + ]); + $this->addForeignKey('fk_menus_parent_id_menus_id', '{{%menus}}', 'parent_id', '{{%menus}}', 'id'); + } + + public function down() + { + $this->dropForeignKey('fk_menus_parent_id_menus_id', '{{%menus}}'); + $this->dropTable('{{%menus}}'); + } +} diff --git a/tests/specs/menu/models/Menu.php b/tests/specs/menu/models/Menu.php new file mode 100644 index 00000000..2b5867b4 --- /dev/null +++ b/tests/specs/menu/models/Menu.php @@ -0,0 +1,10 @@ +language); + $uniqueFaker = new UniqueGenerator($faker); + $model = new Menu(); + $model->id = $uniqueFaker->numberBetween(0, 2147483647); + $model->name = substr($faker->text(100), 0, 100); + $model->args = []; + $model->kwargs = []; + return $model; + } +} diff --git a/tests/specs/menu/models/base/Menu.php b/tests/specs/menu/models/base/Menu.php new file mode 100644 index 00000000..4f103bc1 --- /dev/null +++ b/tests/specs/menu/models/base/Menu.php @@ -0,0 +1,44 @@ + 'Parent'], + [['name'], 'string'], + [['args', 'kwargs'], 'safe'], + ]; + } + + public function getParent() + { + return $this->hasOne(\app\models\Menu::class,['id' => 'parent_id']); + } + public function getChildes() + { + return $this->hasMany(\app\models\Menu::class,['parent_id' => 'id']); + } +} diff --git a/tests/specs/petstore/models/PetFaker.php b/tests/specs/petstore/models/PetFaker.php index 4d589cca..428aa401 100644 --- a/tests/specs/petstore/models/PetFaker.php +++ b/tests/specs/petstore/models/PetFaker.php @@ -14,7 +14,7 @@ public function generateModel() { $faker = FakerFactory::create(\Yii::$app->language); $uniqueFaker = new UniqueGenerator($faker); - $model = new Pet; + $model = new Pet(); $model->id = $uniqueFaker->numberBetween(0, 2147483647); $model->name = $faker->sentence; $model->tag = $faker->randomElement(['one', 'two', 'three', 'four']); diff --git a/tests/specs/petstore/models/StoreFaker.php b/tests/specs/petstore/models/StoreFaker.php index 848e9c7b..e921def8 100644 --- a/tests/specs/petstore/models/StoreFaker.php +++ b/tests/specs/petstore/models/StoreFaker.php @@ -14,7 +14,7 @@ public function generateModel() { $faker = FakerFactory::create(\Yii::$app->language); $uniqueFaker = new UniqueGenerator($faker); - $model = new Store; + $model = new Store(); $model->id = $uniqueFaker->numberBetween(0, 2147483647); $model->name = $faker->sentence; return $model; diff --git a/tests/specs/petstore/models/base/Pet.php b/tests/specs/petstore/models/base/Pet.php index a8da2c71..243c6bf5 100644 --- a/tests/specs/petstore/models/base/Pet.php +++ b/tests/specs/petstore/models/base/Pet.php @@ -24,13 +24,14 @@ public function rules() return [ [['name', 'tag'], 'trim'], [['name'], 'required'], + [['store_id'], 'integer'], + [['store_id'], 'exist', 'targetRelation' => 'Store'], [['name', 'tag'], 'string'], ]; } public function getStore() { - return $this->hasOne(\app\models\Store::class, ['id' => 'store_id']); + return $this->hasOne(\app\models\Store::class,['id' => 'store_id']); } - } diff --git a/tests/specs/petstore_arrayref/models/PetFaker.php b/tests/specs/petstore_arrayref/models/PetFaker.php index 4d589cca..428aa401 100644 --- a/tests/specs/petstore_arrayref/models/PetFaker.php +++ b/tests/specs/petstore_arrayref/models/PetFaker.php @@ -14,7 +14,7 @@ public function generateModel() { $faker = FakerFactory::create(\Yii::$app->language); $uniqueFaker = new UniqueGenerator($faker); - $model = new Pet; + $model = new Pet(); $model->id = $uniqueFaker->numberBetween(0, 2147483647); $model->name = $faker->sentence; $model->tag = $faker->randomElement(['one', 'two', 'three', 'four']); diff --git a/tests/specs/petstore_namespace/mymodels/base/Pet.php b/tests/specs/petstore_namespace/mymodels/base/Pet.php index db6b20eb..c3331c98 100644 --- a/tests/specs/petstore_namespace/mymodels/base/Pet.php +++ b/tests/specs/petstore_namespace/mymodels/base/Pet.php @@ -24,13 +24,14 @@ public function rules() return [ [['name', 'tag'], 'trim'], [['name'], 'required'], + [['store_id'], 'integer'], + [['store_id'], 'exist', 'targetRelation' => 'Store'], [['name', 'tag'], 'string'], ]; } public function getStore() { - return $this->hasOne(\app\mymodels\Store::class, ['id' => 'store_id']); + return $this->hasOne(\app\mymodels\Store::class,['id' => 'store_id']); } - } diff --git a/tests/specs/petstore_namespace/mymodels/faker/PetFaker.php b/tests/specs/petstore_namespace/mymodels/faker/PetFaker.php index ded8f5f3..2caf6c6f 100644 --- a/tests/specs/petstore_namespace/mymodels/faker/PetFaker.php +++ b/tests/specs/petstore_namespace/mymodels/faker/PetFaker.php @@ -15,7 +15,7 @@ public function generateModel() { $faker = FakerFactory::create(\Yii::$app->language); $uniqueFaker = new UniqueGenerator($faker); - $model = new Pet; + $model = new Pet(); $model->id = $uniqueFaker->numberBetween(0, 2147483647); $model->name = $faker->sentence; $model->tag = $faker->randomElement(['one', 'two', 'three', 'four']); diff --git a/tests/specs/petstore_namespace/mymodels/faker/StoreFaker.php b/tests/specs/petstore_namespace/mymodels/faker/StoreFaker.php index 6f18f9ef..be14e6bb 100644 --- a/tests/specs/petstore_namespace/mymodels/faker/StoreFaker.php +++ b/tests/specs/petstore_namespace/mymodels/faker/StoreFaker.php @@ -15,7 +15,7 @@ public function generateModel() { $faker = FakerFactory::create(\Yii::$app->language); $uniqueFaker = new UniqueGenerator($faker); - $model = new Store; + $model = new Store(); $model->id = $uniqueFaker->numberBetween(0, 2147483647); $model->name = $faker->sentence; return $model; diff --git a/tests/specs/petstore_xtable.php b/tests/specs/petstore_xtable.php index db56afbc..e0349384 100644 --- a/tests/specs/petstore_xtable.php +++ b/tests/specs/petstore_xtable.php @@ -6,5 +6,5 @@ 'generateModels' => true, 'generateModelsOnlyXTable' => true, 'generateControllers' => false, - 'generateMigrations' => false, // TODO add tests for migrations + 'generateMigrations' => true, ]; diff --git a/tests/specs/petstore_xtable/migrations/m200000_000000_create_table_pets.php b/tests/specs/petstore_xtable/migrations/m200000_000000_create_table_pets.php new file mode 100644 index 00000000..f712c5ba --- /dev/null +++ b/tests/specs/petstore_xtable/migrations/m200000_000000_create_table_pets.php @@ -0,0 +1,21 @@ +createTable('{{%pets}}', [ + 'id' => $this->bigPrimaryKey(), + 'name' => $this->text()->notNull(), + 'tag' => $this->text()->null()->defaultValue(null), + ]); + } + + public function down() + { + $this->dropTable('{{%pets}}'); + } +} diff --git a/tests/specs/petstore_xtable/models/PetFaker.php b/tests/specs/petstore_xtable/models/PetFaker.php index 4d589cca..428aa401 100644 --- a/tests/specs/petstore_xtable/models/PetFaker.php +++ b/tests/specs/petstore_xtable/models/PetFaker.php @@ -14,7 +14,7 @@ public function generateModel() { $faker = FakerFactory::create(\Yii::$app->language); $uniqueFaker = new UniqueGenerator($faker); - $model = new Pet; + $model = new Pet(); $model->id = $uniqueFaker->numberBetween(0, 2147483647); $model->name = $faker->sentence; $model->tag = $faker->randomElement(['one', 'two', 'three', 'four']); diff --git a/tests/unit/AttributeResolverTest.php b/tests/unit/AttributeResolverTest.php new file mode 100644 index 00000000..35694e95 --- /dev/null +++ b/tests/unit/AttributeResolverTest.php @@ -0,0 +1,69 @@ +resolve(); + echo $schemaName.PHP_EOL; + self::assertEquals($expected->name, $model->name); + self::assertEquals($expected->tableName, $model->tableName); + self::assertEquals($expected->description, $model->description); + self::assertEquals($expected->tableAlias, $model->tableAlias); + foreach ($model->relations as $name => $relation){ + self::assertTrue(isset($expected->relations[$name])); + self::assertEquals($expected->relations[$name], $relation); + } + foreach ($model->attributes as $name => $attribute){ + self::assertTrue(isset($expected->attributes[$name])); + self::assertEquals($expected->attributes[$name], $attribute); + } + } + + public function dataProvider():array + { + $schemaFile = Yii::getAlias("@specs/blog.yaml"); + $fixture = require Yii::getAlias('@fixtures/blog.php'); + $openApi = Reader::readFromYamlFile($schemaFile, OpenApi::class, false); + return [ + [ + 'User', + $openApi->components->schemas['User'], + $fixture['user'] + ], + [ + 'Category', + $openApi->components->schemas['Category'], + $fixture['category'] + ], + [ + 'Post', + $openApi->components->schemas['Post'], + $fixture['post'] + ], + [ + 'Comment', + $openApi->components->schemas['Comment'], + $fixture['comment'] + ], + ]; + } +} \ No newline at end of file diff --git a/tests/unit/FakerStubResolverTest.php b/tests/unit/FakerStubResolverTest.php new file mode 100644 index 00000000..62b21cfa --- /dev/null +++ b/tests/unit/FakerStubResolverTest.php @@ -0,0 +1,131 @@ + FakerStubResolver::class], [$column, $property]); + self::assertEquals($expected, $resolver->resolve()); + } + + public function dataProvider() + { + $schemaFile = Yii::getAlias("@specs/blog.yaml"); + $openApi = Reader::readFromYamlFile($schemaFile, OpenApi::class, false); + $schema = $openApi->components->schemas['Fakerable']; + return [ + [ + (new Attribute('id'))->setPhpType('int')->setDbType(YiiDbSchema::TYPE_BIGPK), + $schema->properties['id'], + '$uniqueFaker->numberBetween(0, 2147483647)', + ], + [ + (new Attribute('someint'))->setPhpType('int')->setDbType(YiiDbSchema::TYPE_BIGPK), + $schema->properties['id'], + '$faker->numberBetween(0, 2147483647)', + ], + [ + (new Attribute('active'))->setPhpType('bool')->setDbType(YiiDbSchema::TYPE_BOOLEAN), + $schema->properties['active'], + '$faker->boolean', + ], + [ + (new Attribute('floatval'))->setPhpType('float')->setDbType(YiiDbSchema::TYPE_FLOAT), + $schema->properties['floatval'], + '$faker->randomFloat()', + ], + [ + (new Attribute('doubleval')) + ->setPhpType(TypeResolver::schemaToPhpType($schema->properties['doubleval'])) + ->setDbType(TypeResolver::schemaToDbType($schema->properties['doubleval'])), + $schema->properties['doubleval'], + '$faker->randomFloat()', + ], + [ + (new Attribute('floatval_lim')) + ->setPhpType('float')->setDbType(YiiDbSchema::TYPE_FLOAT) + ->setLimits(0, 1, null), + $schema->properties['floatval_lim'], + '$faker->randomFloat(null, 0, 1)', + ], + [ + (new Attribute('int_simple')) + ->setPhpType('int')->setDbType(YiiDbSchema::TYPE_INTEGER), + $schema->properties['int_simple'], + '$faker->numberBetween(0, 2147483647)', + ], + [ + (new Attribute('int_created_at')) + ->setPhpType('int')->setDbType(YiiDbSchema::TYPE_INTEGER), + $schema->properties['int_created_at'], + '$faker->unixTime', + ], + [ + (new Attribute('int_min')) + ->setPhpType('int')->setDbType(YiiDbSchema::TYPE_INTEGER) + ->setLimits(5, null, null), + $schema->properties['int_min'], + '$faker->numberBetween(5, 2147483647)', + ], + [ + (new Attribute('int_max')) + ->setPhpType('int')->setDbType(YiiDbSchema::TYPE_INTEGER) + ->setLimits(null, 5, null), + $schema->properties['int_max'], + '$faker->numberBetween(0, 5)', + ], + [ + (new Attribute('int_minmax')) + ->setPhpType('int')->setDbType(YiiDbSchema::TYPE_INTEGER) + ->setLimits(5, 25, null), + $schema->properties['int_minmax'], + '$faker->numberBetween(5, 25)', + ], + [ + (new Attribute('uuid'))->setPhpType('string')->setDbType('uuid'), + $schema->properties['uuid'], + '$faker->uuid', + ], + [ + (new Attribute('str_text'))->setPhpType('string')->setDbType(YiiDbSchema::TYPE_TEXT), + $schema->properties['str_text'], + '$faker->sentence', + ], + [ + (new Attribute('str_varchar'))->setPhpType('string')->setDbType(YiiDbSchema::TYPE_STRING), + $schema->properties['str_varchar'], + '$faker->sentence', + ], + [ + (new Attribute('str_varchar'))->setPhpType('string')->setDbType(YiiDbSchema::TYPE_STRING)->setSize(100), + $schema->properties['str_varchar'], + 'substr($faker->text(100), 0, 100)', + ], + [ + (new Attribute('str_date'))->setPhpType('string')->setDbType(YiiDbSchema::TYPE_DATE), + $schema->properties['str_date'], + '$faker->iso8601', + ], + [ + (new Attribute('str_datetime'))->setPhpType('string')->setDbType(YiiDbSchema::TYPE_DATETIME), + $schema->properties['str_datetime'], + '$faker->dateTimeThisCentury->format(\'Y-m-d H:i:s\')', + ], + ]; + } +} \ No newline at end of file diff --git a/tests/GeneratorTest.php b/tests/unit/GeneratorTest.php similarity index 60% rename from tests/GeneratorTest.php rename to tests/unit/GeneratorTest.php index 44cc6fd2..189cca49 100644 --- a/tests/GeneratorTest.php +++ b/tests/unit/GeneratorTest.php @@ -1,24 +1,20 @@ false, 'only' => ['*.php']]); $ret = []; - foreach($tests as $testFile) { + foreach ($tests as $testFile) { $ret[] = [substr($testFile, strlen(Yii::getAlias('@specs')) + 1)]; } return $ret; @@ -30,35 +26,42 @@ public function provideTestcases() public function testGenerate($testFile) { $testFile = Yii::getAlias("@specs/$testFile"); - FileHelper::removeDirectory(__DIR__ . '/tmp'); - FileHelper::createDirectory(__DIR__ . '/tmp'); - Yii::setAlias('@app', __DIR__ . '/tmp'); + $this->prepareTempDir(); - $app = new \yii\web\Application([ - 'id' => 'yii2-openapi-test', - 'basePath' => __DIR__ . '/tmp', - ]); + $this->mockApplication($this->mockDbSchemaAsEmpty()); $generator = $this->createGenerator($testFile); $this->assertTrue($generator->validate(), print_r($generator->getErrors(), true)); $codeFiles = $generator->generate(); - foreach($codeFiles as $file) { + foreach ($codeFiles as $file) { $file->save(); } $expectedFiles = array_map(function($file) use ($testFile) { return '@app' . substr($file, strlen($testFile) - 4); - }, FileHelper::findFiles(substr($testFile, 0, -4), ['recursive' => true])); - $actualFiles = array_map(function($file) use ($testFile) { + }, + FileHelper::findFiles(substr($testFile, 0, -4), ['recursive' => true])); + $actualFiles = array_map(function($file) { return '@app' . substr($file, strlen(Yii::getAlias('@app'))); - }, FileHelper::findFiles(Yii::getAlias('@app'), ['recursive' => true])); + }, + FileHelper::findFiles(Yii::getAlias('@app'), ['recursive' => true])); + + //Skip database-specific migrations + $expectedFiles = array_filter($expectedFiles, + function($file) { + return strpos($file, 'migrations_') === false; + }); + $actualFiles = array_filter($actualFiles, + function($file) { + return strpos($file, 'migrations_') === false; + }); sort($expectedFiles); sort($actualFiles); $this->assertEquals($expectedFiles, $actualFiles); - foreach($expectedFiles as $file) { + foreach ($expectedFiles as $file) { $expectedFile = str_replace('@app', substr($testFile, 0, -4), $file); $actualFile = str_replace('@app', Yii::getAlias('@app'), $file); $this->assertFileExists($expectedFile); @@ -66,4 +69,10 @@ public function testGenerate($testFile) $this->assertFileEquals($expectedFile, $actualFile, "Failed asserting that file contents of\n$actualFile\nare equal to file contents of\n$expectedFile"); } } + + protected function createGenerator($configFile) + { + $config = require $configFile; + return new ApiGenerator($config); + } } diff --git a/tests/unit/MigrationsGeneratorTest.php b/tests/unit/MigrationsGeneratorTest.php new file mode 100644 index 00000000..409cf77c --- /dev/null +++ b/tests/unit/MigrationsGeneratorTest.php @@ -0,0 +1,87 @@ +prepareTempDir(); + $this->mockApplication($this->mockDbSchemaAsEmpty()); + $generator = new MigrationsGenerator(); + $models = $generator->generate($dbModels); + $model = \array_values($models)[0]; + self::assertInstanceOf(MigrationModel::class, $model); + self::assertEquals($expected[0]->fileName, $model->fileName); + self::assertEquals($expected[0]->dependencies, $model->dependencies); + self::assertCount(count($expected[0]->upCodes), $model->upCodes); + self::assertCount(count($expected[0]->downCodes), $model->downCodes); + self::assertEquals(trim($expected[0]->getUpCodeString()), trim($model->getUpCodeString())); + self::assertEquals(trim($expected[0]->getDownCodeString()), trim($model->getDownCodeString())); + } + + public function tableSchemaStub(string $tableName):?TableSchema + { + $stub = []; + return $stub[$tableName] ?? null; + } + + public function simpleDbModelsProvider():array + { + $dbModel = new DbModel([ + 'name' => 'dummy', + 'tableName' => 'dummy', + 'attributes' => [ + (new Attribute('id'))->setPhpType('int')->setDbType(Schema::TYPE_PK) + ->setRequired(true)->setReadOnly(true), + (new Attribute('title'))->setPhpType('string') + ->setDbType('string') + ->setUnique(true) + ->setSize(60) + ->setRequired(true), + (new Attribute('article'))->setPhpType('string')->setDbType('text')->setDefault(''), + ], + ]); + $codes = str_replace(PHP_EOL, + PHP_EOL . MigrationBuilder::INDENT, + VarDumper::export([ + 'id' => '$this->primaryKey()', + 'title' => '$this->string(60)->notNull()->unique()', + 'article' => '$this->text()->null()->defaultValue("")', + ])); + $expect = new MigrationModel($dbModel, true, [ + 'dependencies' => [], + 'upCodes' => [ + "\$this->createTable('{{%dummy}}', $codes);", + ], + 'downCodes' => [ + "\$this->dropTable('{{%dummy}}');", + ], + ]); + return [ + [ + [$dbModel], + [$expect], + ], + ]; + } +} diff --git a/tests/unit/MultiDbFreshMigrationTest.php b/tests/unit/MultiDbFreshMigrationTest.php new file mode 100644 index 00000000..0a86726b --- /dev/null +++ b/tests/unit/MultiDbFreshMigrationTest.php @@ -0,0 +1,122 @@ +set('db', Yii::$app->maria); + $this->assertInstanceOf(MySqlSchema::class, Yii::$app->db->schema); + $testFile = Yii::getAlias('@specs/blog.php'); + $this->runGenerator($testFile, $dbName); + $expectedFiles = $this->findExpectedFiles($testFile, $dbName); + $actualFiles = $this->findActualFiles(); + $this->assertEquals($expectedFiles, $actualFiles); + $this->compareFiles($expectedFiles, $testFile); + } + + public function testPostgres() + { + $dbName = 'pgsql'; + Yii::$app->set('db', Yii::$app->pgsql); + $this->assertInstanceOf(PgSqlSchema::class, Yii::$app->db->schema); + $testFile = Yii::getAlias('@specs/blog.php'); + $this->runGenerator($testFile, $dbName); + $expectedFiles = $this->findExpectedFiles($testFile, $dbName); + $actualFiles = $this->findActualFiles(); + $this->assertEquals($expectedFiles, $actualFiles); + $this->compareFiles($expectedFiles, $testFile); + } + + public function testMysql() + { + $dbName = 'mysql'; + Yii::$app->set('db', Yii::$app->mysql); + $this->assertInstanceOf(MySqlSchema::class, Yii::$app->db->schema); + $testFile = Yii::getAlias('@specs/blog.php'); + $this->runGenerator($testFile, $dbName); + $expectedFiles = $this->findExpectedFiles($testFile, $dbName); + $actualFiles = $this->findActualFiles(); + $this->assertEquals($expectedFiles, $actualFiles); + $this->compareFiles($expectedFiles, $testFile); + } + + protected function setUp() + { + if (getenv('IN_DOCKER') !== 'docker') { + $this->markTestSkipped('For docker env only'); + } + $this->prepareTempDir(); + $this->mockApplication(); + parent::setUp(); + } + + protected function tearDown() + { + parent::tearDown(); + if (getenv('IN_DOCKER') === 'docker') { + $this->destroyApplication(); + } + } + + protected function runGenerator($configFile, string $dbName) + { + $config = require $configFile; + $config['migrationPath'] = "@app/migrations_{$dbName}_db/"; + $generator = new ApiGenerator($config); + self::assertTrue($generator->validate(), print_r($generator->getErrors(), true)); + + $codeFiles = $generator->generate(); + foreach ($codeFiles as $file) { + $file->save(); + } + } + + protected function compareFiles(array $expected, string $testFile) + { + foreach ($expected as $file) { + $expectedFile = str_replace('@app', substr($testFile, 0, -4), $file); + $actualFile = str_replace('@app', Yii::getAlias('@app'), $file); + self::assertFileExists($expectedFile); + self::assertFileExists($actualFile); + $this->assertFileEquals($expectedFile, $actualFile, "Failed asserting that file contents of\n$actualFile\nare equal to file contents of\n$expectedFile"); + } + } + + protected function findActualFiles():array + { + $actualFiles = array_map(function($file) { + return '@app' . substr($file, strlen(Yii::getAlias('@app'))); + }, + FileHelper::findFiles(Yii::getAlias('@app'), ['recursive' => true])); + \sort($actualFiles); + return $actualFiles; + } + + protected function findExpectedFiles(string $testFile, string $dbName):array + { + $expectedFiles = array_map(function($file) use ($testFile) { + return '@app' . substr($file, strlen($testFile) - 4); + }, + FileHelper::findFiles(substr($testFile, 0, -4), ['recursive' => true])); + + $expectedFiles = array_filter($expectedFiles, + function($file) use ($dbName) { + return strpos($file, 'models') !== false || strpos($file, $dbName) !== false; + }); + \sort($expectedFiles); + return $expectedFiles; + } +} diff --git a/tests/unit/MultiDbSecondaryMigrationTest.php b/tests/unit/MultiDbSecondaryMigrationTest.php new file mode 100644 index 00000000..0627ccc1 --- /dev/null +++ b/tests/unit/MultiDbSecondaryMigrationTest.php @@ -0,0 +1,123 @@ +set('db', Yii::$app->maria); + $this->assertInstanceOf(MySqlSchema::class, Yii::$app->db->schema); + $testFile = Yii::getAlias('@specs/blog_v2.php'); + $this->runGenerator($testFile, $dbName); + $expectedFiles = $this->findExpectedFiles($testFile, $dbName); + $actualFiles = $this->findActualFiles(); + $this->assertEquals($expectedFiles, $actualFiles); + $this->compareFiles($expectedFiles, $testFile); + } + + public function testPostgres() + { + $dbName = 'pgsql'; + Yii::$app->set('db', Yii::$app->pgsql); + $this->assertInstanceOf(PgSqlSchema::class, Yii::$app->db->schema); + $testFile = Yii::getAlias('@specs/blog_v2.php'); + $this->runGenerator($testFile, $dbName); + $expectedFiles = $this->findExpectedFiles($testFile, $dbName); + $actualFiles = $this->findActualFiles(); + $this->assertEquals($expectedFiles, $actualFiles); + $this->compareFiles($expectedFiles, $testFile); + } + + public function testMysql() + { + $dbName = 'mysql'; + Yii::$app->set('db', Yii::$app->mysql); + $this->assertInstanceOf(MySqlSchema::class, Yii::$app->db->schema); + $testFile = Yii::getAlias('@specs/blog_v2.php'); + $this->runGenerator($testFile, $dbName); + $expectedFiles = $this->findExpectedFiles($testFile, $dbName); + $actualFiles = $this->findActualFiles(); + $this->assertEquals($expectedFiles, $actualFiles); + $this->compareFiles($expectedFiles, $testFile); + } + + protected function setUp() + { + if (getenv('IN_DOCKER') !== 'docker') { + $this->markTestSkipped('For docker env only'); + } + $this->prepareTempDir(); + $this->mockApplication(); + parent::setUp(); + } + + protected function tearDown() + { + parent::tearDown(); + if (getenv('IN_DOCKER') === 'docker') { + $this->destroyApplication(); + } + } + + protected function runGenerator($configFile, string $dbName) + { + $config = require $configFile; + $config['migrationPath'] = "@app/migrations_{$dbName}_db/"; + $config['generateModels'] = false; + $generator = new ApiGenerator($config); + self::assertTrue($generator->validate(), print_r($generator->getErrors(), true)); + + $codeFiles = $generator->generate(); + foreach ($codeFiles as $file) { + $file->save(); + } + } + + protected function compareFiles(array $expected, string $testFile) + { + foreach ($expected as $file) { + $expectedFile = str_replace('@app', substr($testFile, 0, -4), $file); + $actualFile = str_replace('@app', Yii::getAlias('@app'), $file); + self::assertFileExists($expectedFile); + self::assertFileExists($actualFile); + $this->assertFileEquals($expectedFile, $actualFile, "Failed asserting that file contents of\n$actualFile\nare equal to file contents of\n$expectedFile"); + } + } + + protected function findActualFiles():array + { + $actualFiles = array_map(function($file) { + return '@app' . substr($file, strlen(Yii::getAlias('@app'))); + }, + FileHelper::findFiles(Yii::getAlias('@app'), ['recursive' => true])); + \sort($actualFiles); + return $actualFiles; + } + + protected function findExpectedFiles(string $testFile, string $dbName):array + { + $expectedFiles = array_map(function($file) use ($testFile) { + return '@app' . substr($file, strlen($testFile) - 4); + }, + FileHelper::findFiles(substr($testFile, 0, -4), ['recursive' => true])); + + $expectedFiles = array_filter($expectedFiles, + function($file) use ($dbName) { + return strpos($file, $dbName) !== false; + }); + \sort($expectedFiles); + return $expectedFiles; + } +} diff --git a/tests/unit/TypeResolverTest.php b/tests/unit/TypeResolverTest.php new file mode 100644 index 00000000..1850bd0d --- /dev/null +++ b/tests/unit/TypeResolverTest.php @@ -0,0 +1,71 @@ +'integer', 'format'=>'int64']), true, YiiDbSchema::TYPE_BIGPK], + [new Schema(['type'=>'integer', 'format'=>'int32']), true, YiiDbSchema::TYPE_PK], + [new Schema(['type'=>'boolean']), false, YiiDbSchema::TYPE_BOOLEAN], + [new Schema(['type'=>'number', 'format'=>'float']), false, YiiDbSchema::TYPE_FLOAT], + [new Schema(['type'=>'number', 'format'=>'double']), false, YiiDbSchema::TYPE_DOUBLE], + [new Schema(['type'=>'integer', 'format'=>'date-time']), false, YiiDbSchema::TYPE_INTEGER], + [new Schema(['type'=>'string']), false, YiiDbSchema::TYPE_TEXT], + [new Schema(['type'=>'string', 'x-db-type'=>'varchar']), false, YiiDbSchema::TYPE_STRING], + [new Schema(['type'=>'string', 'x-db-type'=>'JSON']), false, YiiDbSchema::TYPE_JSON], + [new Schema(['type'=>'string', 'x-db-type'=>'tsvector']), false, 'tsvector'], + [new Schema(['type'=>'string', 'enum'=>['a', 'b', 'c']]), false, 'string'], + [new Schema(['type'=>'string', 'format'=>'email']), false, YiiDbSchema::TYPE_STRING], + [new Schema(['type'=>'string', 'maxLength'=>100]), false, YiiDbSchema::TYPE_STRING], + [new Schema(['type'=>'string', 'maxLength'=>10000]), false, YiiDbSchema::TYPE_TEXT], + [new Schema(['type'=>'string', 'format'=>'date-time']), false, YiiDbSchema::TYPE_DATETIME], + ]; + } + + /** + * @dataProvider forPhpTypeDataProvider + * @param \cebe\openapi\spec\Schema $property + * @param string $expected + */ + public function testResolvePhpType(Schema $property, string $expected):void + { + self::assertEquals($expected, TypeResolver::schemaToPhpType($property)); + } + + public function forPhpTypeDataProvider():array + { + return [ + [new Schema(['type'=>'integer', 'format'=>'int64']), 'int'], + [new Schema(['type'=>'integer']), 'int'], + [new Schema(['type'=>'boolean']), 'bool'], + [new Schema(['type'=>'number']), 'float'], + [new Schema(['type'=>'number', 'format'=>'float']), 'float'], + [new Schema(['type'=>'number', 'format'=>'double']), 'double'], + [new Schema(['type'=>'string']), 'string'], + [new Schema(['type'=>'string', 'format'=>'date']), 'string'], + [new Schema(['type'=>'array']), 'array'], + [new Schema(['type'=>'string', 'x-db-type'=>'json']), 'array'], + [new Schema(['type'=>'integer', 'x-db-type'=>'int[]']), 'array'], + ]; + } + +} \ No newline at end of file diff --git a/tests/unit/ValidatorRulesBuilderTest.php b/tests/unit/ValidatorRulesBuilderTest.php new file mode 100644 index 00000000..ac73b03e --- /dev/null +++ b/tests/unit/ValidatorRulesBuilderTest.php @@ -0,0 +1,47 @@ + 'dummy', + 'tableName' => 'dummy', + 'attributes' => [ + (new Attribute('id'))->setPhpType('int')->setDbType(Schema::TYPE_PK) + ->setRequired(true)->setReadOnly(true), + (new Attribute('title'))->setPhpType('string') + ->setDbType('string') + ->setUnique(true) + ->setSize(60) + ->setRequired(true), + (new Attribute('article'))->setPhpType('string')->setDbType('text')->setDefault(''), + (new Attribute('active'))->setPhpType('bool')->setDbType('boolean'), + (new Attribute('category'))->asReference('Category') + ->setRequired(true)->setPhpType('int')->setDbType('integer') + ], + ]); + $expected = [ + new ValidationRule(['title', 'article'], 'trim'), + new ValidationRule(['title', 'category_id'], 'required'), + new ValidationRule(['category_id'], 'integer'), + new ValidationRule(['category_id'], 'exist', ['targetRelation'=>'Category']), + new ValidationRule(['title', 'article'], 'string'), + new ValidationRule(['active'], 'boolean'), + ]; + + $rules = (new ValidationRulesBuilder($model))->build(); + $this->assertEquals($expected, $rules); + } + + +} \ No newline at end of file diff --git a/tests/unit/items/ValidationRuleTest.php b/tests/unit/items/ValidationRuleTest.php new file mode 100644 index 00000000..d3c12a60 --- /dev/null +++ b/tests/unit/items/ValidationRuleTest.php @@ -0,0 +1,74 @@ +assertEquals($expected, (string)$rule); + } + + public function testRulesToString() + { + $rules = [ + new ValidationRule(['foo'], 'required'), + new ValidationRule(['foo', 'bar'], 'trim'), + ]; + $result = implode(",\n", $rules); + $this->assertEquals($result, " [['foo'], 'required'],\n [['foo', 'bar'], 'trim']"); + } + + public function dataProvider():array + { + return [ + [ + new ValidationRule(['foo'], 'required'), + " [['foo'], 'required']", + ], + [ + new ValidationRule(['foo', 'bar'], 'trim'), + " [['foo', 'bar'], 'trim']", + ], + [ + new ValidationRule(['foo'], 'string', ['min' => 1, 'max' => 500]), + " [['foo'], 'string', 'min' => 1, 'max' => 500]", + ], + [ + new ValidationRule(['foo'], 'default', ['value' => null]), + " [['foo'], 'default', 'value' => null]", + ], + [ + new ValidationRule(['foo'], 'filter', ['filter' => 'intval', 'skipOnEmpty' => true]), + " [['foo'], 'filter', 'filter' => 'intval', 'skipOnEmpty' => true]", + ], + [ + new ValidationRule(['foo'], 'in', ['range' => ['one', 'two', 'three']]), + " [['foo'], 'in', 'range' => ['one', 'two', 'three']]", + ], + [ + new ValidationRule(['foo'], 'exist', ['targetAttribute' => ['a2', 'a1' => 'a3']]), + " [['foo'], 'exist', 'targetAttribute' => ['a2', 'a1' => 'a3']]", + ], + [ + new ValidationRule(['foo'], 'filter', [ + 'filter' => function($v) { + return strtolower($v); + }, + ]), + " [['foo'], 'filter', 'filter' => '']", + ], + ]; + } +} diff --git a/tests/yii b/tests/yii new file mode 100755 index 00000000..6a694787 --- /dev/null +++ b/tests/yii @@ -0,0 +1,7 @@ +#!/usr/bin/env php +run(); +exit($exitCode);