From 5d120a83767d1fb02dfec2d1d072256b9063af73 Mon Sep 17 00:00:00 2001 From: Marcus Eibrink-Lunzenauer <lunzenauer@elan-ev.de> Date: Wed, 24 Nov 2021 08:46:12 +0100 Subject: [PATCH] add Factories and Seeders --- cli/Commands/DB/Seed.php | 49 ++ cli/studip | 3 + composer.json | 3 +- composer.lock | 67 ++ .../Factories/BelongsToManyRelationship.php | 61 ++ .../Factories/BelongsToRelationship.php | 76 +++ lib/classes/Database/Factories/Factory.php | 635 ++++++++++++++++++ lib/classes/Database/Factories/HasFactory.php | 31 + .../Database/Factories/Relationship.php | 54 ++ lib/classes/Database/Factories/Sequence.php | 53 ++ lib/classes/Database/Seeder.php | 25 + .../Database/Seeders/ActivitiesSeeder.php | 73 ++ lib/classes/Database/Seeders/CourseSeeder.php | 45 ++ .../Database/Seeders/DatabaseSeeder.php | 19 + .../Database/Seeders/ExampleSeeder.php | 89 +++ lib/models/SimpleORMap.class.php | 5 + lib/models/factories/CourseFactory.php | 51 ++ .../factories/Courseware/BlockFactory.php | 29 + .../factories/Courseware/ContainerFactory.php | 41 ++ .../Courseware/StructuralElementFactory.php | 52 ++ lib/models/factories/UserFactory.php | 35 + tests/jsonapi/ModelFactoryTest.php | 224 ++++++ 22 files changed, 1719 insertions(+), 1 deletion(-) create mode 100644 cli/Commands/DB/Seed.php create mode 100644 lib/classes/Database/Factories/BelongsToManyRelationship.php create mode 100644 lib/classes/Database/Factories/BelongsToRelationship.php create mode 100644 lib/classes/Database/Factories/Factory.php create mode 100644 lib/classes/Database/Factories/HasFactory.php create mode 100644 lib/classes/Database/Factories/Relationship.php create mode 100644 lib/classes/Database/Factories/Sequence.php create mode 100644 lib/classes/Database/Seeder.php create mode 100644 lib/classes/Database/Seeders/ActivitiesSeeder.php create mode 100644 lib/classes/Database/Seeders/CourseSeeder.php create mode 100644 lib/classes/Database/Seeders/DatabaseSeeder.php create mode 100644 lib/classes/Database/Seeders/ExampleSeeder.php create mode 100644 lib/models/factories/CourseFactory.php create mode 100644 lib/models/factories/Courseware/BlockFactory.php create mode 100644 lib/models/factories/Courseware/ContainerFactory.php create mode 100644 lib/models/factories/Courseware/StructuralElementFactory.php create mode 100644 lib/models/factories/UserFactory.php create mode 100644 tests/jsonapi/ModelFactoryTest.php diff --git a/cli/Commands/DB/Seed.php b/cli/Commands/DB/Seed.php new file mode 100644 index 00000000000..2c674edce01 --- /dev/null +++ b/cli/Commands/DB/Seed.php @@ -0,0 +1,49 @@ +<?php + +namespace Studip\Cli\Commands\DB; + +use Studip\Database\Seeders\DatabaseSeeder; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +class Seed extends Command +{ + protected static $defaultName = 'db:seed'; + + protected function configure(): void + { + $this->setDescription('Seed the database with records'); + $this->addArgument('class', InputArgument::OPTIONAL, 'The class of the root seeder', DatabaseSeeder::class); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $io->info('Migration starting at ' . date('d.m.Y H:i:s')); + + $this->getSeeder($input)->__invoke(); + + $io->info('Database seeding completed successfully.'); + + return Command::SUCCESS; + } + + /** + * Get a seeder instance from the container. + */ + protected function getSeeder(InputInterface $input): \Studip\Database\Seeder + { + $class = $input->getArgument('class'); + + if (strpos($class, '\\') === false) { + $class = 'Studip\\Database\\Seeders\\' . $class; + } + + $seeder = new $class(); + + return $seeder; + } +} diff --git a/cli/studip b/cli/studip index c4addb93b93..bfb56999e64 100755 --- a/cli/studip +++ b/cli/studip @@ -31,6 +31,9 @@ $commands = [ Commands\Cronjobs\CronjobWorker::class, Commands\Cronjobs\CronjobWorker::class, Commands\DB\Dump::class, + Commands\DB\MigrateEngine::class, + Commands\DB\MigrateFileFormat::class, + Commands\DB\Seed::class, Commands\Files\Dump::class, Commands\Fix\Biest7789::class, Commands\Fix\Biest7866::class, diff --git a/composer.json b/composer.json index b98b402c774..a6288229f42 100644 --- a/composer.json +++ b/composer.json @@ -14,7 +14,8 @@ "codeception/module-asserts": "^1.3", "overtrue/phplint": "^3.0", "phpstan/phpstan": "^1.8", - "symfony/var-dumper": "^5.4" + "symfony/var-dumper": "^5.4", + "fakerphp/faker": "^1.20" }, "require": { "php": "^7.2", diff --git a/composer.lock b/composer.lock index dc1399eff55..d6b215ec8e5 100644 --- a/composer.lock +++ b/composer.lock @@ -5449,6 +5449,73 @@ ], "time": "2022-12-30T00:15:36+00:00" }, + { + "name": "fakerphp/faker", + "version": "v1.20.0", + "source": { + "type": "git", + "url": "https://github.com/FakerPHP/Faker.git", + "reference": "37f751c67a5372d4e26353bd9384bc03744ec77b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/37f751c67a5372d4e26353bd9384bc03744ec77b", + "reference": "37f751c67a5372d4e26353bd9384bc03744ec77b", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "psr/container": "^1.0 || ^2.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "conflict": { + "fzaninotto/faker": "*" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.4.1", + "doctrine/persistence": "^1.3 || ^2.0", + "ext-intl": "*", + "symfony/phpunit-bridge": "^4.4 || ^5.2" + }, + "suggest": { + "doctrine/orm": "Required to use Faker\\ORM\\Doctrine", + "ext-curl": "Required by Faker\\Provider\\Image to download images.", + "ext-dom": "Required by Faker\\Provider\\HtmlLorem for generating random HTML.", + "ext-iconv": "Required by Faker\\Provider\\ru_RU\\Text::realText() for generating real Russian text.", + "ext-mbstring": "Required for multibyte Unicode string functionality." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "v1.20-dev" + } + }, + "autoload": { + "psr-4": { + "Faker\\": "src/Faker/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "François Zaninotto" + } + ], + "description": "Faker is a PHP library that generates fake data for you.", + "keywords": [ + "data", + "faker", + "fixtures" + ], + "support": { + "issues": "https://github.com/FakerPHP/Faker/issues", + "source": "https://github.com/FakerPHP/Faker/tree/v1.20.0" + }, + "time": "2022-07-20T13:12:54+00:00" + }, { "name": "myclabs/deep-copy", "version": "1.11.1", diff --git a/lib/classes/Database/Factories/BelongsToManyRelationship.php b/lib/classes/Database/Factories/BelongsToManyRelationship.php new file mode 100644 index 00000000000..0c3fe0a3513 --- /dev/null +++ b/lib/classes/Database/Factories/BelongsToManyRelationship.php @@ -0,0 +1,61 @@ +<?php + +namespace Studip\Database\Factories; + +use SimpleORMap; + +class BelongsToManyRelationship +{ + /** + * The related factory instance. + * + * @var SimpleORMap + */ + protected $factory; + + /** + * The pivot attributes / attribute resolver. + * + * @var callable|array + */ + protected $pivot; + + /** + * The relationship name. + * + * @var string + */ + protected $relationship; + + /** + * Create a new attached relationship definition. + * + * @param SimpleORMap $factory + * @param callable|array $pivot + * @param string $relationship + * @return void + */ + public function __construct($factory, $pivot, $relationship) + { + $this->factory = $factory; + $this->pivot = $pivot; + $this->relationship = $relationship; + } + + /** + * Create the attached relationship for the given model. + * + * @param SimpleORMap $model + * @return void + */ + public function createFor(SimpleORMap $model) + { + $collection = $this->factory instanceof Factory ? $this->factory->create([], $model) : $this->factory; + + foreach ($collection as $attachable) { + $model + ->{$this->relationship}() + ->attach($attachable, is_callable($this->pivot) ? call_user_func($this->pivot, $model) : $this->pivot); + } + } +} diff --git a/lib/classes/Database/Factories/BelongsToRelationship.php b/lib/classes/Database/Factories/BelongsToRelationship.php new file mode 100644 index 00000000000..7541791d15d --- /dev/null +++ b/lib/classes/Database/Factories/BelongsToRelationship.php @@ -0,0 +1,76 @@ +<?php + +namespace Studip\Database\Factories; + +use SimpleORMap; + +class BelongsToRelationship +{ + /** + * The related factory instance. + * + * @var SimpleORMap + */ + protected $factory; + + /** + * The relationship name. + * + * @var string + */ + protected $relationship; + + /** + * The cached, resolved parent instance ID. + * + * @var mixed + */ + protected $resolved; + + /** + * Create a new "belongs to" relationship definition. + * + * @param SimpleORMap $factory + * @param string $relationship + * @return void + */ + public function __construct($factory, $relationship) + { + $this->factory = $factory; + $this->relationship = $relationship; + } + + /** + * Get the parent model attributes and resolvers for the given child model. + * + * @param SimpleORMap $model + * @return array + */ + public function attributesFor(SimpleORMap $model) + { + $relationship = $model->{$this->relationship}(); + + return [ + $relationship->getForeignKeyName() => $this->resolver($relationship->getOwnerKeyName()), + ]; + } + + /** + * Get the deferred resolver for this relationship's parent ID. + * + * @param string|null $key + * @return \Closure + */ + protected function resolver($key) + { + return function () use ($key) { + if (! $this->resolved) { + $instance = $this->factory instanceof Factory ? $this->factory->create() : $this->factory; + + return $this->resolved = $key ? $instance->{$key} : $instance->getId(); + } + + return $this->resolved; + }; + } +} diff --git a/lib/classes/Database/Factories/Factory.php b/lib/classes/Database/Factories/Factory.php new file mode 100644 index 00000000000..5eab431e0f6 --- /dev/null +++ b/lib/classes/Database/Factories/Factory.php @@ -0,0 +1,635 @@ +<?php + +namespace Studip\Database\Factories; + +use Closure; +use Faker\Generator; +use SimpleORMap; +use Throwable; + +abstract class Factory +{ + /** + * The name of the factory's corresponding model. + * + * @var string|null + */ + protected $model; + + /** + * The number of models that should be generated. + * + * @var int|null + */ + protected $count; + + /** + * The state transformations that will be applied to the model. + * + * @var array + */ + protected $states; + + /** + * The parent relationships that will be applied to the model. + * + * @var array + */ + protected $has; + + /** + * The child relationships that will be applied to the model. + * + * @var array + */ + protected $for; + + /** + * The "after making" callbacks that will be applied to the model. + * + * @var array + */ + protected $afterMaking; + + /** + * The "after creating" callbacks that will be applied to the model. + * + * @var array + */ + protected $afterCreating; + + /** + * The current Faker instance. + * + * @var \Faker\Generator + */ + protected $faker; + + /** + * The default namespace where factories reside. + * + * @var string + */ + protected static $namespace = 'Factories\\'; + + /** + * Create a new factory instance. + * + * @param int|null $count + * @param array|null $states + * @param array|null $has + * @param array|null $for + * @param array|null $afterMaking + * @param array|null $afterCreating + * @return void + */ + public function __construct( + $count = null, + $states = null, + $has = null, + $for = null, + $afterMaking = null, + $afterCreating = null + ) { + $this->count = $count; + $this->states = $states ?: []; + $this->has = $has ?: []; + $this->for = $for ?: []; + $this->afterMaking = $afterMaking ?: []; + $this->afterCreating = $afterCreating ?: []; + $this->faker = $this->withFaker(); + } + + /** + * Define the model's default state. + * + * @return array + */ + abstract public function definition(); + + /** + * Get a new factory instance for the given attributes. + * + * @param callable|array $attributes + * @return static + */ + public static function new($attributes = []) + { + return (new static())->state($attributes)->configure(); + } + + /** + * Get a new factory instance for the given number of models. + * + * @param int $count + * @return static + */ + public static function times(int $count) + { + return static::new()->count($count); + } + + /** + * Configure the factory. + * + * @return $this + */ + public function configure() + { + return $this; + } + + /** + * Get the raw attributes generated by the factory. + * + * @param array $attributes + * @param SimpleORMap|null $parent + * @return array + */ + public function raw($attributes = [], ?SimpleORMap $parent = null) + { + if ($this->count === null) { + return $this->state($attributes)->getExpandedAttributes($parent); + } + + return array_map(function () use ($attributes, $parent) { + return $this->state($attributes)->getExpandedAttributes($parent); + }, range(1, $this->count)); + } + + /** + * Create a single model and persist it to the database. + * + * @param array $attributes + * @return SimpleORMap + */ + public function createOne($attributes = []) + { + return $this->count(null)->create($attributes); + } + + /** + * Create a collection of models and persist them to the database. + * + * @param iterable $records + * @return iterable + */ + public function createMany(iterable $records) + { + $many = []; + foreach ($records as $record) { + $many[] = $this->state($record)->create(); + } + + return $many; + } + + /** + * Create a collection of models and persist them to the database. + * + * @param array $attributes + * @param SimpleORMap|null $parent + * @return array|SimpleORMap + */ + public function create($attributes = [], ?SimpleORMap $parent = null) + { + if (!empty($attributes)) { + return $this->state($attributes)->create([], $parent); + } + + $results = $this->make($attributes, $parent); + + if ($results instanceof SimpleORMap) { + $this->store([$results]); + + $this->callAfterCreating([$results], $parent); + } else { + $this->store($results); + + $this->callAfterCreating($results, $parent); + } + + return $results; + } + + /** + * Create a callback that persists a model in the database when invoked. + * + * @param array $attributes + * @param SimpleORMap|null $parent + * @return \Closure + */ + public function lazy(array $attributes = [], ?SimpleORMap $parent = null) + { + return function () use ($attributes, $parent) { + return $this->create($attributes, $parent); + }; + } + + /** + * Store the results. + * + * @param array $results + * @return void + */ + protected function store($results) + { + foreach ($results as $model) { + $model->store(); + $this->createChildren($model); + } + } + + /** + * Create the children for the given model. + * + * @param SimpleORMap $model + * @return void + */ + protected function createChildren(SimpleORMap $model) + { + foreach ($this->has as $has) { + $has->createFor($model); + } + } + + /** + * Make a single instance of the model. + * + * @param callable|array $attributes + * @return SimpleORMap + */ + public function makeOne($attributes = []) + { + return $this->count(null)->make($attributes); + } + + /** + * Create a collection of models. + * + * @param array $attributes + * @param SimpleORMap|null $parent + * @return array|SimpleORMap + */ + public function make($attributes = [], ?SimpleORMap $parent = null) + { + if (!empty($attributes)) { + return $this->state($attributes)->make([], $parent); + } + + if ($this->count === null) { + $instance = $this->makeInstance($parent); + $this->callAfterMaking([$instance]); + + return $instance; + } + + if ($this->count < 1) { + return []; + } + + $instances = array_map(function () use ($parent) { + return $this->makeInstance($parent); + }, range(1, $this->count)); + + $this->callAfterMaking($instances); + + return $instances; + } + + /** + * Make an instance of the model with the given attributes. + * + * @param SimpleORMap|null $parent + * @return SimpleORMap + */ + protected function makeInstance(?SimpleORMap $parent) + { + return $this->newModel($this->getExpandedAttributes($parent)); + } + + /** + * Get a raw attributes array for the model. + * + * @param SimpleORMap|null $parent + * @return mixed + */ + protected function getExpandedAttributes(?SimpleORMap $parent) + { + return $this->expandAttributes($this->getRawAttributes($parent)); + } + + /** + * Get the raw attributes for the model as an array. + * + * @param SimpleORMap|null $parent + * @return array + */ + protected function getRawAttributes(?SimpleORMap $parent) + { + $states = + count($this->for) === 0 + ? $this->states + : array_merge( + [ + function () { + return $this->parentResolvers(); + }, + ], + $this->states + ); + + $rawAttributes = array_reduce( + $states, + function ($carry, $state) use ($parent) { + if ($state instanceof Closure) { + $state = $state->bindTo($this); + } + $mergeMe = $state($carry, $parent); + + return array_merge($carry, $mergeMe); + }, + $this->definition() + ); + + return $rawAttributes; + } + + /** + * Create the parent relationship resolvers (as deferred Closures). + * + * @return array + */ + protected function parentResolvers() + { + $model = $this->newModel(); + + $resolvers = []; + foreach ($this->for as $for) { + $resolvers[] = $for->attributesFor($model); + } + + return $resolvers; + } + + /** + * Expand all attributes to their underlying values. + * + * @param array $definition + * @return array + */ + protected function expandAttributes(array $definition) + { + foreach ($definition as $key => $attribute) { + if (is_callable($attribute) && !is_string($attribute) && !is_array($attribute)) { + $attribute = $attribute($definition); + } + + if ($attribute instanceof self) { + $attribute = $attribute->create()->getId(); + } elseif ($attribute instanceof SimpleORMap) { + $attribute = $attribute->getId(); + } + + $definition[$key] = $attribute; + } + + return $definition; + } + + /** + * Add a new state transformation to the model definition. + * + * @param callable|array $state + * @return static + */ + public function state($state) + { + $states = array_merge($this->states, [ + is_callable($state) + ? $state + : function () use ($state) { + return $state; + }, + ]); + + return $this->newInstance([ + 'states' => $states, + ]); + } + + /** + * Add a new sequenced state transformation to the model definition. + * + * @param array $sequence + * @return static + */ + public function sequence(...$sequence) + { + return $this->state(new Sequence(...$sequence)); + } + + /** + * Define a child relationship for the model. + * + * @param \Studip\Database\Factories\Factory $factory + * @param string|null $relationship + * @return static + */ + public function has(self $factory, $relationship) + { + $has = array_merge($this->has, [new Relationship($factory, $relationship)]); + + return $this->newInstance(['has' => $has]); + } + + /** + * Get a new Faker instance. + * + * @return \Faker\Generator + */ + protected function withFaker() + { + return \Faker\Factory::create(); + } + + /** + * Define a parent relationship for the model. + * + * @param \Studip\Database\Factories\Factory|SimpleORMap $factory + * @param string $relationship + * @return static + */ + public function for($factory, $relationship) + { + $forWhom = array_merge($this->for, [new BelongsToRelationship($factory, $relationship)]); + + return $this->newInstance(['for' => $forWhom]); + } + + /** + * Add a new "after making" callback to the model definition. + * + * @param \Closure $callback + * @return static + */ + public function afterMaking(Closure $callback) + { + $afterMaking = array_merge($this->afterMaking, [$callback]); + + return $this->newInstance(['afterMaking' => $afterMaking]); + } + + /** + * Add a new "after creating" callback to the model definition. + * + * @param \Closure $callback + * @return static + */ + public function afterCreating(Closure $callback) + { + $afterCreating = array_merge($this->afterCreating, [$callback]); + + return $this->newInstance(['afterCreating' => $afterCreating]); + } + + /** + * Call the "after making" callbacks for the given model instances. + * + * @param array $instances + * @return void + */ + protected function callAfterMaking(array $instances) + { + foreach ($instances as $model) { + foreach ($this->afterMaking as $callback) { + $callback($model); + } + } + } + + /** + * Call the "after creating" callbacks for the given model instances. + * + * @param array $instances + * @param SimpleORMap|null $parent + * @return void + */ + protected function callAfterCreating(array $instances, ?SimpleORMap $parent = null) + { + foreach ($instances as $model) { + foreach ($this->afterCreating as $callback) { + $callback($model, $parent); + } + } + } + + /** + * Specify how many models should be generated. + * + * @param int|null $count + * @return static + */ + public function count(?int $count) + { + return $this->newInstance(['count' => $count]); + } + + /** + * Create a new instance of the factory builder with the given mutated properties. + * + * @param array $arguments + * @return static + */ + protected function newInstance(array $arguments = []) + { + return new static( + ...array_values( + array_merge( + [ + 'count' => $this->count, + 'states' => $this->states, + 'has' => $this->has, + 'for' => $this->for, + 'afterMaking' => $this->afterMaking, + 'afterCreating' => $this->afterCreating, + ], + $arguments + ) + ) + ); + } + + /** + * Get a new model instance. + * + * @param array $attributes + * @return SimpleORMap + */ + public function newModel(array $attributes = []) + { + $model = $this->modelName(); + + return $model::build($attributes); + } + + /** + * Get the name of the model that is generated by the factory. + * + * @return string + */ + public function modelName() + { + return $this->model; + } + + /** + * Specify the default namespace that contains the application's model factories. + * + * @param string $namespace + * @return void + */ + public static function useNamespace(string $namespace) + { + static::$namespace = $namespace; + } + + /** + * Get a new factory instance for the given model name. + * + * @param string $modelName + * @return static + */ + public static function factoryForModel(string $modelName) + { + $factory = static::resolveFactoryName($modelName); + + return $factory::new(); + } + + /** + * Get the factory name for the given model name. + * + * @param string $modelName + * @return string + */ + public static function resolveFactoryName(string $modelName) + { + return self::$namespace . $modelName . 'Factory'; + } + + /** + * Proxy dynamic factory methods onto their proper methods. + * + * @param string $method + * @param array $parameters + * @return mixed + */ + public function __call($method, $parameters) + { + $slice = substr($method, 0, 3); + if (!in_array($slice, ['for', 'has'])) { + throw new \BadMethodCallException($method); + } + } +} diff --git a/lib/classes/Database/Factories/HasFactory.php b/lib/classes/Database/Factories/HasFactory.php new file mode 100644 index 00000000000..c3a8a078573 --- /dev/null +++ b/lib/classes/Database/Factories/HasFactory.php @@ -0,0 +1,31 @@ +<?php + +namespace Studip\Database\Factories; + +trait HasFactory +{ + /** + * Get a new factory instance for the model. + * + * @param mixed $parameters + * @return \Studip\Database\Factories\Factory + */ + public static function factory(...$parameters) + { + $factory = static::newFactory() ?: Factory::factoryForModel(get_called_class()); + + return $factory + ->count(is_numeric($parameters[0] ?? null) ? $parameters[0] : null) + ->state(is_array($parameters[0] ?? null) ? $parameters[0] : $parameters[1] ?? []); + } + + /** + * Create a new factory instance for the model. + * + * @return \Studip\Database\Factories\Factory + */ + protected static function newFactory() + { + // + } +} diff --git a/lib/classes/Database/Factories/Relationship.php b/lib/classes/Database/Factories/Relationship.php new file mode 100644 index 00000000000..f3e7de2fde3 --- /dev/null +++ b/lib/classes/Database/Factories/Relationship.php @@ -0,0 +1,54 @@ +<?php + +namespace Studip\Database\Factories; + +use SimpleORMap; + +class Relationship +{ + /** + * The related factory instance. + * + * @var Factory + */ + protected $factory; + + /** + * The relationship name. + * + * @var string + */ + protected $relationship; + + /** + * Create a new child relationship instance. + * + * @param Factory $factory + * @param string $relationship + * @return void + */ + public function __construct(Factory $factory, $relationship) + { + $this->factory = $factory; + $this->relationship = $relationship; + } + + /** + * Create the child relationship for the given parent model. + * + * @param SimpleORMap $parent + * @return void + */ + public function createFor(SimpleORMap $parent) + { + $relationship = $parent->getRelationOptions($this->relationship); + + if (in_array($relationship['type'], ['has_one', 'has_many'])) { + $this->factory->state([ + $relationship['assoc_foreign_key'] => $parent[$relationship['foreign_key']], + ])->create([], $parent); + } elseif (in_array($relationship['type'], ['belongs_to'])) { + $relationship->attach($this->factory->create([], $parent)); + } + } +} diff --git a/lib/classes/Database/Factories/Sequence.php b/lib/classes/Database/Factories/Sequence.php new file mode 100644 index 00000000000..faf69e34803 --- /dev/null +++ b/lib/classes/Database/Factories/Sequence.php @@ -0,0 +1,53 @@ +<?php + +namespace Studip\Database\Factories; + +class Sequence +{ + /** + * The sequence of return values. + * + * @var array + */ + protected $sequence; + + /** + * The count of the sequence items. + * + * @var int + */ + public $count; + + /** + * The current index of the sequence iteration. + * + * @var int + */ + public $index = 0; + + /** + * Create a new sequence instance. + * + * @param array $sequence + * @return void + */ + public function __construct(...$sequence) + { + $this->sequence = $sequence; + $this->count = count($sequence); + } + + /** + * Get the next value in the sequence. + * + * @return mixed + */ + public function __invoke() + { + $value = $this->sequence[$this->index % $this->count]; + $result = $value instanceof \Closure ? $value($this) : $value; + $this->index = $this->index + 1; + + return $result; + } +} diff --git a/lib/classes/Database/Seeder.php b/lib/classes/Database/Seeder.php new file mode 100644 index 00000000000..fe122873c87 --- /dev/null +++ b/lib/classes/Database/Seeder.php @@ -0,0 +1,25 @@ +<?php + +namespace Studip\Database; + +use InvalidArgumentException; + +abstract class Seeder +{ + /** + * Run the database seeds. + * + * @param array $parameters + * @return mixed + * + * @throws \InvalidArgumentException + */ + public function __invoke(array $parameters = []) + { + if (!method_exists($this, 'run')) { + throw new InvalidArgumentException('Method [run] missing from ' . get_class($this)); + } + + return $this->run(...$parameters); + } +} diff --git a/lib/classes/Database/Seeders/ActivitiesSeeder.php b/lib/classes/Database/Seeders/ActivitiesSeeder.php new file mode 100644 index 00000000000..83a887b2cd0 --- /dev/null +++ b/lib/classes/Database/Seeders/ActivitiesSeeder.php @@ -0,0 +1,73 @@ +<?php + +namespace Studip\Database\Seeders; + +use Studip\Database\Seeder; + +class ActivitiesSeeder extends Seeder +{ + /** + * Run the database seeders. + * + * @return void + */ + public function run() + { + $course = \Course::find('a07535cf2f8a72df33c12ddfa4b53dde'); + if (!$course) { + throw new \RuntimeException('Could not find course.'); + } + + $students = \User::factory() + ->count(35) + ->afterMaking(function (\User $user) use ($course) { + $user->course_memberships[] = \CourseMember::build([ + 'Seminar_id' => $course->getId(), + 'user_id' => $user->getId(), + 'status' => 'autor', + ]); + }) + ->create(); + + $faker = \Faker\Factory::create(); + + foreach ($students as $student) { + self::withUser($student, function ($student) use ($faker, $course) { + if (rand() / getrandmax() < 0.25) { + // add wiki page + \WikiPage::create([ + 'range_id' => $course->getId(), + 'user_id' => $student->getId(), + 'keyword' => $faker->words(2, true), + 'body' => $faker->sentence(3), + 'ancestor' => 'WikiWikiWeb', + 'version' => 0, + ]); + } + + // send message + // enter course + // leave course + // create file + // update file + // delete file + // create news + // add wiki page + // delete wiki page + // CourseDidChangeSchedule??? + }); + } + } + + private static function withUser(\User $user, callable $callable) + { + $oldUser = $GLOBALS['user']; + $GLOBALS['user'] = new \Seminar_User($user); + + $result = $callable($user); + + $GLOBALS['user'] = $oldUser; + + return $result; + } +} diff --git a/lib/classes/Database/Seeders/CourseSeeder.php b/lib/classes/Database/Seeders/CourseSeeder.php new file mode 100644 index 00000000000..b32d90c6b66 --- /dev/null +++ b/lib/classes/Database/Seeders/CourseSeeder.php @@ -0,0 +1,45 @@ +<?php + +namespace Studip\Database\Seeders; + +use Studip\Database\Seeder; + +class CourseSeeder extends Seeder +{ + /** + * Run the database seeders. + * + * @return void + */ + public function run() + { + /** @var ?\User $testDozent */ + $testDozent = \User::findByUsername('test_dozent'); + if (!$testDozent) { + throw new \RuntimeException('Cannot create course without `test_dozent`.'); + } + $members = [\CourseMember::build(['user_id' => $testDozent->getId(), 'status' => 'dozent'])]; + + $course = \Course::factory() + ->count(200) + ->afterMaking(function (\Course $course) use ($testDozent) { + $course->members[] = \CourseMember::build([ + 'Seminar_id' => $course->getId(), + 'user_id' => $testDozent->getId(), + 'status' => 'dozent', + ]); + }) + ->create(); + + // $students = \User::factory() + // ->count(35) + // ->afterMaking(function (\User $user) use ($course) { + // $user->course_memberships[] = \CourseMember::build([ + // 'Seminar_id' => $course->getId(), + // 'user_id' => $user->getId(), + // 'status' => 'autor', + // ]); + // }) + // ->create(); + } +} diff --git a/lib/classes/Database/Seeders/DatabaseSeeder.php b/lib/classes/Database/Seeders/DatabaseSeeder.php new file mode 100644 index 00000000000..506136f27e4 --- /dev/null +++ b/lib/classes/Database/Seeders/DatabaseSeeder.php @@ -0,0 +1,19 @@ +<?php + +namespace Studip\Database\Seeders; + +use Studip\Database\Seeder; + +class DatabaseSeeder extends Seeder +{ + /** + * Seed the application's database. + * + * @return void + */ + public function run() + { + var_dump(__FILE__, __LINE__); + // \App\Models\User::factory(10)->create(); + } +} diff --git a/lib/classes/Database/Seeders/ExampleSeeder.php b/lib/classes/Database/Seeders/ExampleSeeder.php new file mode 100644 index 00000000000..992ba00b182 --- /dev/null +++ b/lib/classes/Database/Seeders/ExampleSeeder.php @@ -0,0 +1,89 @@ +<?php + +namespace Studip\Database\Seeders; + +use Studip\Database\Seeder; + +class ExampleSeeder extends Seeder +{ + /** + * Run the database seeders. + * + * @return void + */ + public function run() + { + $course = \Course::find('a07535cf2f8a72df33c12ddfa4b53dde'); + if (!$course) { + throw new \RuntimeException('Could not find course.'); + } + $instance = \Courseware\StructuralElement::createEmptyCourseware($course->getId(), 'course'); + $parent = $instance->getRoot(); + + $students = \User::factory() + ->count(35) + ->afterMaking(function (\User $user) use ($course) { + $user->course_memberships[] = \CourseMember::build([ + 'Seminar_id' => $course->getId(), + 'user_id' => $user->getId(), + 'status' => 'autor', + ]); + }) + ->create(); + + $lvl1 = \Courseware\StructuralElement::factory() + ->count(25) + ->withParent($parent) + ->create(); + + foreach ($lvl1 as $parent1) { + $lvl2 = \Courseware\StructuralElement::factory() + ->count(12) + ->withParent($parent1) + ->create(); + + foreach ($lvl2 as $parent2) { + $lvl3 = \Courseware\StructuralElement::factory() + ->count(1) + ->withParent($parent2) + ->has($this->containersWithBlocks(), 'containers') + ->create(); + } + } + + foreach ($instance->findAllBlocks() as $block) { + foreach ($students as $student) { + if (mt_rand(0, 1)) { + \Courseware\UserProgress::create([ + 'user_id' => $student->getId(), + 'block_id' => $block->getId(), + 'grade' => 1, + ]); + } + } + } + } + + private function containersWithBlocks(): \Studip\Database\Factories\Factory + { + return \Courseware\Container::factory() + ->count(2) + ->state(function (array $attributes, \Courseware\StructuralElement $parent) { + return [ + 'owner_id' => $parent->owner_id, + 'editor_id' => $parent->editor_id, + ]; + }) + ->has( + \Courseware\Block::factory() + ->count(10) + ->state(function (array $attributes, \Courseware\Container $parent) { + return [ + 'owner_id' => $parent->owner_id, + 'editor_id' => $parent->editor_id, + ]; + }), + 'blocks' + ); + } +} diff --git a/lib/models/SimpleORMap.class.php b/lib/models/SimpleORMap.class.php index 8f951b4abc3..5ba2157ce11 100644 --- a/lib/models/SimpleORMap.class.php +++ b/lib/models/SimpleORMap.class.php @@ -1,4 +1,7 @@ <?php + +use Studip\Database\Factories\HasFactory; + /** * SimpleORMap.class.php * simple object-relational mapping @@ -16,6 +19,8 @@ class SimpleORMap implements ArrayAccess, Countable, IteratorAggregate { + use HasFactory; + /** * Defines `_` as character used when joining composite primary keys. */ diff --git a/lib/models/factories/CourseFactory.php b/lib/models/factories/CourseFactory.php new file mode 100644 index 00000000000..d9d5fbb4106 --- /dev/null +++ b/lib/models/factories/CourseFactory.php @@ -0,0 +1,51 @@ +<?php + +namespace Factories; + +use Studip\Database\Factories\Factory; + +class CourseFactory extends Factory +{ + protected $model = \Course::class; + + public function definition() + { + return [ + 'Seminar_id' => $this->faker->md5(), + // VeranstaltungsNummer + // ! Institut_id + 'Name' => $this->faker->sentence(3), + // Untertitel + 'status' => 1, // TODO: this should be something else I guess + // Beschreibung + // Ort + // Sonstiges + 'Lesezugriff' => 1, + 'Schreibzugriff' => 1, + 'start_time' => \Semester::findCurrent()->beginn, + 'duration_time' => 0, + // art + // teilnehmer + // vorrausetzungen + // lernorga + // leistungsnachweis + // - mkdate + // - chdate + // - ects + 'admission_turnout' => 0, + 'admission_binding' => 0, + 'admission_prelim' => 0, + // 'admission_prelim_txt' => null, + 'admission_disable_waitlist' => 0, + 'visible' => 1, + 'showscore' => 0, + // 'aux_lock_rule' => null, + 'aux_lock_rule_forced' => 0, + // 'lock_rule' => null, + 'admission_waitlist_max' => 0, + 'admission_disable_waitlist_move' => 0, + 'completion' => 0, + // 'parent_course' => null, + ]; + } +} diff --git a/lib/models/factories/Courseware/BlockFactory.php b/lib/models/factories/Courseware/BlockFactory.php new file mode 100644 index 00000000000..da23a569e00 --- /dev/null +++ b/lib/models/factories/Courseware/BlockFactory.php @@ -0,0 +1,29 @@ +<?php + +namespace Factories\Courseware; + +use Studip\Database\Factories\Factory; + +class BlockFactory extends Factory +{ + protected $model = \Courseware\Block::class; + + public function definition() + { + return [ + 'block_type' => 'text', + 'visible' => true, + + // `position` int(11) NOT NULL, + // `payload` MEDIUMTEXT NOT NULL, + + // `container_id` int(11) NOT NULL, + // `owner_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL, + // `editor_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL, + // `edit_blocker_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin NULL, + + // `mkdate` int(11) NOT NULL, + // `chdate` int(11) NOT NULL, + ]; + } +} diff --git a/lib/models/factories/Courseware/ContainerFactory.php b/lib/models/factories/Courseware/ContainerFactory.php new file mode 100644 index 00000000000..1744da9c975 --- /dev/null +++ b/lib/models/factories/Courseware/ContainerFactory.php @@ -0,0 +1,41 @@ +<?php + +namespace Factories\Courseware; + +use Courseware\Container; +use Courseware\StructuralElement; +use Studip\Database\Factories\Factory; + +class ContainerFactory extends Factory +{ + protected $model = Container::class; + + public function definition() + { + return [ + 'container_type' => 'list', + // structural_element_id + // owner_id + // editor_id + // edit_blocker_id + // position + // site + // container_type + // visible + // payload + // mkdate + // chdate + ]; + } + + public function withElement(StructuralElement $element): Factory + { + return $this->state(function (array $attributes) use ($element) { + return [ + 'structural_element_id' => $element->getId(), + 'owner_id' => $element['owner_id'], + 'editor_id' => $element['owner_id'], + ]; + }); + } +} diff --git a/lib/models/factories/Courseware/StructuralElementFactory.php b/lib/models/factories/Courseware/StructuralElementFactory.php new file mode 100644 index 00000000000..3451d514fb1 --- /dev/null +++ b/lib/models/factories/Courseware/StructuralElementFactory.php @@ -0,0 +1,52 @@ +<?php + +namespace Factories\Courseware; + +use Courseware\StructuralElement; +use Studip\Database\Factories\Factory; + +class StructuralElementFactory extends Factory +{ + protected $model = StructuralElement::class; + + public function definition() + { + return [ + 'title' => $this->faker->sentence(), + + // parent_id + // range_id + // range_type + // owner_id + // editor_id + // edit_blocker_id + // position + // title + // image_id + // purpose + // payload + // public + // release_date + // withdraw_date + // read_approval + // write_approval + // copy_approval + // external_relations + // mkdate + // chdate + ]; + } + + public function withParent(StructuralElement $parent): Factory + { + return $this->state(function (array $attributes) use ($parent) { + return [ + 'parent_id' => $parent->getId(), + 'range_id' => $parent['range_id'], + 'range_type' => $parent['range_type'], + 'owner_id' => $parent['owner_id'], + 'editor_id' => $parent['owner_id'], + ]; + }); + } +} diff --git a/lib/models/factories/UserFactory.php b/lib/models/factories/UserFactory.php new file mode 100644 index 00000000000..bbc49efc511 --- /dev/null +++ b/lib/models/factories/UserFactory.php @@ -0,0 +1,35 @@ +<?php + +namespace Factories; + +use Studip\Database\Factories\Factory; + +class UserFactory extends Factory +{ + protected $model = \User::class; + + public function definition() + { + return [ + 'username' => $this->faker->userName(), + // `password` varbinary(64) NOT NULL DEFAULT '', + 'perms' => 'autor', + 'Vorname' => $this->faker->firstName(), + 'Nachname' => $this->faker->lastName(), + 'Email' => $this->faker->email(), + // `validation_key` varchar(10) NOT NULL DEFAULT '', + // `auth_plugin` varchar(64) DEFAULT 'standard', + // `locked` tinyint(1) UNSIGNED NOT NULL DEFAULT '0', + // `lock_comment` varchar(255) DEFAULT NULL, + // `locked_by` varchar(32) DEFAULT NULL, + 'visible' => 'yes', + ]; + } + + public function invisible(): Factory + { + return $this->state(function (array $attributes) { + return ['visible' => 'no']; + }); + } +} diff --git a/tests/jsonapi/ModelFactoryTest.php b/tests/jsonapi/ModelFactoryTest.php new file mode 100644 index 00000000000..adb593b5181 --- /dev/null +++ b/tests/jsonapi/ModelFactoryTest.php @@ -0,0 +1,224 @@ +<?php + +use Studip\Database\Factories\Sequence; + +class ModelFactoryTest extends \Codeception\Test\Unit +{ + /** + * @var \UnitTester + */ + protected $tester; + + protected function _before() + { + \DBManager::getInstance()->setConnection('studip', $this->getModule('\\Helper\\StudipDb')->dbh); + } + + protected function _after() + { + } + + // tests + + /** + * @group specification + */ + public function testSORMClassesShouldProvideAFactory() + { + $factory = \User::factory(); + $this->assertInstanceOf(\Studip\Database\Factories\Factory::class, $factory); + $this->assertInstanceOf(\Studip\Database\Factories\UserFactory::class, $factory); + } + + /** + * @group specification + */ + public function testShouldMakeAnInstance() + { + $user = \User::factory()->make(); + $this->assertInstanceOf(\User::class, $user); + $this->assertEquals('autor', $user['perms']); + } + + /** + * @group specification + */ + public function testShouldMakeAnInstanceWithMoreAttributes() + { + $user = \User::factory()->make(['username' => 'foobar']); + $this->assertInstanceOf(\User::class, $user); + $this->assertEquals('foobar', $user['username']); + } + + /** + * @group specification + */ + public function testShouldMakeAnInstanceWithInlineState() + { + $user = \User::factory() + ->state(['username' => 'bazbar']) + ->make(); + $this->assertInstanceOf(\User::class, $user); + $this->assertEquals('bazbar', $user['username']); + } + + /** + * @group specification + */ + public function testShouldMakeANumberOfInstances() + { + $users = \User::factory() + ->count(3) + ->make(); + $this->assertIsArray($users); + $this->assertCount(3, $users); + $this->assertContainsOnlyInstancesOf(\User::class, $users); + } + + /** + * @group specification + */ + public function testShouldCreateAnInstanceInTheDatabase() + { + $countUsers = \User::countBySql('1'); + + $user = \User::factory()->create(); + $this->assertInstanceOf(\User::class, $user); + $this->assertFalse($user->isNew()); + + $this->assertEquals($countUsers + 1, \User::countBySql('1')); + } + + /** + * @group specification + */ + public function testShouldCreateAnInstanceInTheDatabaseWithMoreAttributes() + { + $user = \User::factory()->create(['username' => 'foobaz']); + $this->assertEquals('foobaz', $user['username']); + } + + /** + * @group specification + */ + public function testShouldCreateANumberOfInstancesInTheDatabase() + { + $countUsers = \User::countBySql('1'); + + $users = \User::factory() + ->count(3) + ->create(); + $this->assertIsArray($users); + + $this->assertEquals($countUsers + 3, \User::countBySql('1')); + } + + /** + * @group specification + */ + public function testShouldAllowToCallStateMethods() + { + $user = \User::factory() + ->invisible() + ->make(); + $this->assertInstanceOf(\User::class, $user); + $this->assertEquals('no', $user['visible']); + } + + /** + * @group specification + */ + public function testShouldAllowAfterMakingCallbacks() + { + $visited = false; + $user = \User::factory() + ->afterMaking(function (\User $user) use (&$visited) { + $visited = true; + }) + ->make(); + $this->assertInstanceOf(\User::class, $user); + $this->assertTrue($visited); + } + + /** + * @group specification + */ + public function testShouldMakeANumberOfInstancesUsingASequence() + { + $usernames = [ + ['username' => 'deathborgdelta'], + ['username' => 'deathborgzeta'], + ['username' => 'deathborgtheta'], + ]; + $users = \User::factory() + ->count(5) + ->state(new Sequence(...$usernames)) + ->make(); + $this->assertIsArray($users); + $this->assertCount(5, $users); + $this->assertContainsOnlyInstancesOf(\User::class, $users); + for ($i = 0; $i < 3; $i++) { + $this->assertEquals($usernames[$i]['username'], $users[$i]['username']); + } + } + + /** + * @group specification + */ + public function testShouldMakeANumberOfInstancesUsingACallableSequence() + { + $usernames = [ + ['username' => 'deathborgdelta'], + ['username' => 'deathborgzeta'], + ['username' => 'deathborgtheta'], + ]; + $users = \User::factory() + ->count(5) + ->state( + new Sequence(function ($sequence) use ($usernames) { + return isset($usernames[$sequence->index]) ? $usernames[$sequence->index] : []; + }) + ) + ->make(); + $this->assertIsArray($users); + $this->assertCount(5, $users); + $this->assertContainsOnlyInstancesOf(\User::class, $users); + for ($i = 0; $i < 3; $i++) { + $this->assertEquals($usernames[$i]['username'], $users[$i]['username']); + } + } + + /** + * @group specification + */ + public function testShouldMakeANumberOfInstancesUsingSequenceMethod() + { + $usernames = [ + ['username' => 'deathborgdelta'], + ['username' => 'deathborgzeta'], + ['username' => 'deathborgtheta'], + ]; + $users = \User::factory() + ->count(5) + ->sequence(function ($sequence) use ($usernames) { + return isset($usernames[$sequence->index]) ? $usernames[$sequence->index] : []; + }) + ->make(); + $this->assertIsArray($users); + $this->assertCount(5, $users); + $this->assertContainsOnlyInstancesOf(\User::class, $users); + for ($i = 0; $i < 3; $i++) { + $this->assertEquals($usernames[$i]['username'], $users[$i]['username']); + } + } + + /** + * @group specification + */ + public function ytestShouldMakeBlocksWithAnOwner() + { + $block = \Courseware\Block::factory() + ->for(\User::factory(), 'owner') + ->make(); + } +} -- GitLab