diff --git a/composer.json b/composer.json index 7c8bc6d..cd0b176 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,7 @@ "bin-dir": "bin/" }, "require": { - "rhubarbphp/rhubarb": "1.0.x-dev@dev" + "rhubarbphp/rhubarb": "*" }, "require-dev": { "rhubarbphp/custard" : "^1.0.4", diff --git a/src/Collections/Collection.php b/src/Collections/Collection.php index 3071942..5e2dbe9 100644 --- a/src/Collections/Collection.php +++ b/src/Collections/Collection.php @@ -398,7 +398,7 @@ public function filter(Filter $filter) * Where repository specific optimisation is available this will be leveraged to run the batch * update at the data source rather than iterating over the items. * - * @param Array $propertyPairs An associative array of key value pairs to update + * @param array $propertyPairs An associative array of key value pairs to update * @param bool $fallBackToIteration If the repository can't perform the action directly, perform the update by iterating over all the models in the collection. You should only pass true if you know that the collection doesn't meet the criteria for an optimised update and the iteration of items won't cause problems * iterating over all the models in the collection. You should only pass true * if you know that the collection doesn't meet the criteria for an optimised diff --git a/src/Custard/BulkScenario.php b/src/Custard/BulkScenario.php new file mode 100644 index 0000000..48b6f80 --- /dev/null +++ b/src/Custard/BulkScenario.php @@ -0,0 +1,12 @@ +getName(); if (stripos($methodName, 'get') === 0) { + $parameters = $method->getParameters(); + $wrapper->readable = true; + foreach ($parameters as $parameter) { + if (!$parameter->allowsNull()) { + // If a "get" method has any non-nullable parameters, it's not a property getter + $wrapper->readable = false; + break; + } + } + + if (!$wrapper->readable) { + return false; + } } elseif (stripos($methodName, 'set') === 0) { - $wrapper->writable = true; + $parameters = $method->getParameters(); + $paramCount = count($parameters); + + if ($paramCount > 0) { + // A "set" method must take a parameter to be a property setter + $wrapper->writable = true; + + for ($i = 1; $i < $paramCount; $i++) { + if (!$parameter->allowsNull()) { + // If a "set" method has more than 1 non-nullable parameter, it's not a property setter + $wrapper->writable = false; + break; + } + } + } + + if (!$wrapper->writable) { + return false; + } } else { // Neither a getter nor a setter return false; diff --git a/src/Custard/DescribedDemoDataSeederInterface.php b/src/Custard/DescribedDemoDataSeederInterface.php new file mode 100644 index 0000000..c80a551 --- /dev/null +++ b/src/Custard/DescribedDemoDataSeederInterface.php @@ -0,0 +1,27 @@ + Red and bold + * Bold + * Blinking (not supported everywhere) + * + * Other options can be defined and the defaults provided by Symfony are + * still present. You can read more at the link below: + * + * https://symfony.com/doc/current/console/coloring.html + * + * @param Output $output + * @return mixed + */ + public function describeDemoData(Output $output); +} diff --git a/src/Custard/Scenario.php b/src/Custard/Scenario.php new file mode 100644 index 0000000..1406cf1 --- /dev/null +++ b/src/Custard/Scenario.php @@ -0,0 +1,55 @@ +seedScenario = $seedScenario; + $this->scenarioDescription = new ScenarioDescription(); + $this->name = $name; + } + + /** + * @param OutputInterface $output + * Run the data seeder and describe what happened to $output + */ + public function run(OutputInterface $output) + { + $seedScenario = $this->seedScenario; + $seedScenario($this->scenarioDescription); + $this->scenarioDescription->describe($output); + } + + /** + * @return string + */ + public function getName(): string + { + return $this->name; + } +} \ No newline at end of file diff --git a/src/Custard/ScenarioDataSeeder.php b/src/Custard/ScenarioDataSeeder.php new file mode 100644 index 0000000..a5d09d1 --- /dev/null +++ b/src/Custard/ScenarioDataSeeder.php @@ -0,0 +1,77 @@ +getPreRequisiteSeeders() as $seeder) { + if (is_string($seeder)) { + $seeder = new $seeder(); + } + + if (!($seeder instanceof DemoDataSeederInterface)) { + throw new \InvalidArgumentException(get_class($seeder) . " does not extend DemoDataSeederInterface."); + } + + $seeder->seedData($output, $includeBulk); + } + + foreach ($this->getScenarios() as $scenario) { + if ($includeBulk || !($scenario instanceof BulkScenario)) { + $this->beforeScenario($scenario); + + $output->writeln(""); + $output->writeln("Scenario " . self::$scenarioCount . ": " . $scenario->getName() . ''); + $output->writeln(str_repeat('-', 11 + strlen(self::$scenarioCount) + strlen($scenario->getName()))); + $scenario->run($output); + self::$scenarioCount++; + + $this->afterScenario($scenario); + } + } + } + + /** + * @return Scenario[] + */ + abstract function getScenarios(): array; +} diff --git a/src/Custard/ScenarioDescription.php b/src/Custard/ScenarioDescription.php new file mode 100644 index 0000000..f238912 --- /dev/null +++ b/src/Custard/ScenarioDescription.php @@ -0,0 +1,32 @@ +lines[] = $spacer . $line; + return $this; + } + + /** + * @param OutputInterface $output + * Write all oif the lines + */ + public function describe(OutputInterface $output) + { + $output->writeln($this->lines); + } +} \ No newline at end of file diff --git a/src/Custard/SeedDemoDataCommand.php b/src/Custard/SeedDemoDataCommand.php index b45825a..e3d85a8 100644 --- a/src/Custard/SeedDemoDataCommand.php +++ b/src/Custard/SeedDemoDataCommand.php @@ -4,7 +4,10 @@ use Rhubarb\Stem\Models\Model; use Rhubarb\Stem\Schema\SolutionSchema; +use Symfony\Component\Console\Formatter\OutputFormatterStyle; use Symfony\Component\Console\Helper\ProgressBar; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -13,7 +16,12 @@ class SeedDemoDataCommand extends RequiresRepositoryCommand protected function configure() { $this->setName('stem:seed-data') - ->setDescription('Seeds the repositories with demo data'); + ->setDescription('Seeds the repositories with demo data') + ->addOption("list", "l", null, "Lists the seeders available") + ->addOption("obliterate", "o", InputOption::VALUE_NONE, "Obliterate the entire database first") + ->addOption("bulk", "b", InputOption::VALUE_NONE, "Include bulk seed sets") + ->addOption("force", "f", InputOption::VALUE_NONE, "Forces obliteration even if running only a single seeder") + ->addArgument("seeder", InputArgument::OPTIONAL, "The name of the seeder to run, leave out for all"); parent::configure(); } @@ -23,57 +31,138 @@ protected function configure() */ private static $seeders = []; + private static $enableTruncating = false; + + /** + * True if we're presently seeding the database + * + * Used to stop event chains based of model chains that the seeder should not cause. + * e.g. user activation emails + */ + public static $seeding = false; + protected function executeWithConnection(InputInterface $input, OutputInterface $output) { + SeedDemoDataCommand::$seeding = true; + + $output->getFormatter()->setStyle('bold', new OutputFormatterStyle(null, null, ['bold'])); + $output->getFormatter()->setStyle('blink', new OutputFormatterStyle(null, null, ['blink'])); + $output->getFormatter()->setStyle('critical', new OutputFormatterStyle('red', null, ['bold'])); + + if ($input->getOption("list") != null) { + + $output->writeln("Listing possible seeders:"); + $output->writeln(""); + + foreach (self::$seeders as $seeder) { + $output->writeln("\t" . basename(str_replace("\\", "/", get_class($seeder)))); + } + + $output->writeln(""); + return; + } + + $chosenSeeder = $input->getArgument("seeder"); + + if (($input->getOption("obliterate") === true) && (!empty($chosenSeeder))) { + // Running a single seeder after an obliteration makes no sense - this is probably + // a mistake. + if ($input->getOption("force") === false) { + $output->writeln("Running a single seeder after obliteration is probably not sane. Use with -f to force."); + return; + } + } + parent::executeWithConnection($input, $output); - $this->writeNormal("Clearing existing data.", true); + $this->writeNormal("Updating table schemas...", true); $schemas = SolutionSchema::getAllSchemas(); - $modelSchemas = []; - foreach ($schemas as $schema) { - $modelSchemas = array_merge($modelSchemas, $schema->getAllModels()); + $schema->checkModelSchemas(); } - $progressBar = new ProgressBar($output, sizeof($modelSchemas)); + if (($input->getOption("obliterate") === true) || self::$enableTruncating) { - foreach ($modelSchemas as $alias => $modelClass) { - $progressBar->advance(); + $this->writeNormal("Clearing existing data.", true); - /** @var Model $model */ - $model = new $modelClass(); - $schema = $model->getSchema(); - $repository = $model->getRepository(); + $modelSchemas = []; - $this->writeNormal(" Truncating " . str_pad(basename($schema->schemaName), 50, ' ', STR_PAD_RIGHT)); + foreach ($schemas as $schema) { + $modelSchemas = array_merge($modelSchemas, $schema->getAllModels()); + } - $repository->clearRepositoryData(); - } + $progressBar = new ProgressBar($output, sizeof($modelSchemas)); - $this->writeNormal("", true); - $this->writeNormal("", true); + foreach ($modelSchemas as $alias => $modelClass) { + $progressBar->advance(); - $this->writeNormal("Running seed scripts...", true); + /** @var Model $model */ + $model = new $modelClass(); + $schema = $model->getSchema(); - $progressBar->finish(); + $repository = $model->getRepository(); - $progressBar = new ProgressBar($output, sizeof($modelSchemas)); + $this->writeNormal(" Truncating " . str_pad(basename($schema->schemaName), 50, ' ', STR_PAD_RIGHT)); - foreach (self::$seeders as $seeder) { - $progressBar->advance(); + $repository->clearRepositoryData(); + } - $this->writeNormal(" Processing " . str_pad(basename(str_replace("\\", "/", get_class($seeder))), 50, ' ', STR_PAD_RIGHT)); + $progressBar->finish(); - $seeder->seedData($output); + $this->writeNormal("", true); + $this->writeNormal("", true); } - $progressBar->finish(); + $this->writeNormal("Running seed scripts...", true); + + $includeBulk = ($input->getOption("bulk") === true); + + if ($chosenSeeder) { + $found = false; + foreach (self::$seeders as $seeder) { + if (strtolower(basename(str_replace("\\", "/", get_class($seeder)))) == strtolower($chosenSeeder)) { + $this->writeNormal(" Processing " . str_pad(basename(str_replace("\\", "/", get_class($seeder))), 50, ' ', STR_PAD_RIGHT)); + $output->writeln(['', '']); + + if ($seeder instanceof DescribedDemoDataSeederInterface) { + $seeder->describeDemoData($output); + } + + $seeder->seedData($output, $includeBulk); + $found = true; + } + } + + if (!$found) { + $output->writeln("No seeder matching `" . $chosenSeeder . "`"); + $this->writeNormal("", true); + + return; + } + } else { + $progressBar = new ProgressBar($output, sizeof(self::$seeders)); + + foreach (self::$seeders as $seeder) { + $progressBar->advance(); + + $this->writeNormal(" Processing " . str_pad(basename(str_replace("\\", "/", get_class($seeder))), 50, ' ', STR_PAD_RIGHT)); + + $seeder->seedData($output, $includeBulk); + } + + $progressBar->finish(); + } $this->writeNormal("", true); - $this->writeNormal("", true); + $this->writeNormal("Seeding Complete", true); + + SeedDemoDataCommand::$seeding = false; + } - $this->writeNormal("Seeding Complete", true); + public static function setEnableTruncating($enableTruncating) + { + self::$enableTruncating = $enableTruncating; } public static function registerDemoDataSeeder(DemoDataSeederInterface $demoDataSeeder) diff --git a/src/Decorators/CommonDataDecorator.php b/src/Decorators/CommonDataDecorator.php index 3eba83e..72affa1 100644 --- a/src/Decorators/CommonDataDecorator.php +++ b/src/Decorators/CommonDataDecorator.php @@ -20,7 +20,6 @@ require_once __DIR__ . '/DataDecorator.php'; -use Rhubarb\Leaf\Presenters\Controls\DateTime\Date; use Rhubarb\Stem\Decorators\Formatters\DecimalFormatter; use Rhubarb\Stem\Models\Model; use Rhubarb\Stem\Schema\Columns\BooleanColumn; @@ -38,14 +37,10 @@ protected function registerTypeDefinitions() return $booleanValue ? "Yes" : "No"; }); - $this->addTypeFormatter(MoneyColumn::class, function (Model $model, $value) { - return number_format($value, 2); - }); - $this->addTypeFormatter(DecimalColumn::class, new DecimalFormatter()); - $this->addTypeFormatter(Date::class, function (Model $model, \DateTime $value) { - return $value->format("d-M-Y"); + $this->addTypeFormatter(MoneyColumn::class, function (Model $model, $value) { + return number_format($value, 2); }); } } diff --git a/src/LoginProviders/ModelLoginProvider.php b/src/LoginProviders/ModelLoginProvider.php index 0675b49..ce56ed7 100644 --- a/src/LoginProviders/ModelLoginProvider.php +++ b/src/LoginProviders/ModelLoginProvider.php @@ -78,34 +78,36 @@ public function login($username, $password) // There should only be one user matching the username. It would be possible to support // unique *combinations* of username and password but it's a potential security issue and // could trip us up when supporting the project. - if (sizeof($list) > 1) { - Log::debug("Login failed for {$username} - the username wasn't unique", "LOGIN"); - throw new LoginFailedException(); + $existingActiveUsers = 0; + foreach ($list as $user) { + if ($this->isModelActive($user)) { + $activeUser = $user; + $existingActiveUsers++; + } + + if ($existingActiveUsers > 1) { + Log::debug("Login failed for {$username} - the username wasn't unique", "LOGIN"); + throw new LoginFailedException(); + } } - /** - * @var Model $user - */ - $user = $list[0]; + if (!isset($activeUser)) { + Log::debug("Login failed for {$username} - the user is disabled.", "LOGIN"); + throw new LoginDisabledException(); + } - $this->checkUserIsPermitted($user); + $this->checkUserIsPermitted($activeUser); // Test the password matches. - $userPasswordHash = $user[$this->passwordColumnName]; + $userPasswordHash = $activeUser[$this->passwordColumnName]; if ($hashProvider->compareHash($password, $userPasswordHash)) { - // Matching login - but is it enabled? - if ($this->isModelActive($user)) { - $this->LoggedIn = true; - $this->LoggedInUserIdentifier = $user->getUniqueIdentifier(); + $this->LoggedIn = true; + $this->LoggedInUserIdentifier = $activeUser->getUniqueIdentifier(); - $this->storeSession(); + $this->storeSession(); - return true; - } else { - Log::debug("Login failed for {$username} - the user is disabled.", "LOGIN"); - throw new LoginDisabledException(); - } + return true; } Log::debug("Login failed for {$username} - the password hash $userPasswordHash didn't match the stored hash.", "LOGIN"); diff --git a/src/Repositories/MySql/MySql.php b/src/Repositories/MySql/MySql.php index 277cb97..2b249d4 100644 --- a/src/Repositories/MySql/MySql.php +++ b/src/Repositories/MySql/MySql.php @@ -252,7 +252,6 @@ public function batchCommitUpdatesFromCollection(Collection $collection, $proper $namedParams[$paramName] = $value; $sets[] = "`" . $key . "` = :" . $paramName; - } $sql = "UPDATE `{$table}` SET " . implode(",", $sets) . $whereClause; @@ -295,6 +294,8 @@ public function getUniqueIdentifiersForDataList(Collection $list, &$unfetchedRow $modelData = array_combine($modelJoinedColumns, $joinedData); + $modelData = $repository->transformDataFromRepository($modelData); + $repository->cachedObjectData[$modelData[$model->UniqueIdentifierColumnName]] = $modelData; } @@ -676,6 +677,12 @@ public static function getConnection(StemSettings $settings) [\PDO::ERRMODE_EXCEPTION => true] ); + if ($settings->Charset != 'utf8') { + // Change charset if it's not the default + $statement = $pdo->prepare("SET NAMES :charset"); + $statement->execute(['charset' => $settings->Charset]); + } + $timeZone = $pdo->query("SELECT @@system_time_zone"); if ($timeZone->rowCount()) { $settings->RepositoryTimeZone = new \DateTimeZone($timeZone->fetchColumn()); diff --git a/src/Repositories/MySql/Schema/Columns/MySqlDateTimeColumn.php b/src/Repositories/MySql/Schema/Columns/MySqlDateTimeColumn.php index df05bed..75da3f7 100644 --- a/src/Repositories/MySql/Schema/Columns/MySqlDateTimeColumn.php +++ b/src/Repositories/MySql/Schema/Columns/MySqlDateTimeColumn.php @@ -19,6 +19,7 @@ namespace Rhubarb\Stem\Repositories\MySql\Schema\Columns; use Rhubarb\Crown\DateTime\RhubarbDateTime; +use Rhubarb\Stem\Repositories\MySql\MySql; use Rhubarb\Stem\Schema\Columns\Column; use Rhubarb\Stem\Schema\Columns\DateTimeColumn; use Rhubarb\Stem\StemSettings; @@ -61,6 +62,9 @@ public function getTransformIntoRepository() $date = clone $data; $settings = new StemSettings(); + if (!$settings->RepositoryTimeZone) { + MySql::getDefaultConnection(); + } if ($settings->RepositoryTimeZone) { // Normalise timezones to default system timezone when stored in DB $date->setTimezone($settings->RepositoryTimeZone); diff --git a/src/Repositories/MySql/Schema/Columns/MySqlIntegerColumn.php b/src/Repositories/MySql/Schema/Columns/MySqlIntegerColumn.php index 5f9ffcd..716ab31 100644 --- a/src/Repositories/MySql/Schema/Columns/MySqlIntegerColumn.php +++ b/src/Repositories/MySql/Schema/Columns/MySqlIntegerColumn.php @@ -37,6 +37,6 @@ public function getDefinition() protected static function fromGenericColumnType(Column $genericColumn) { - return new MySqlIntegerColumn($genericColumn->columnName); + return new self($genericColumn->columnName, $genericColumn->defaultValue); } } diff --git a/src/Schema/Columns/UUIDColumn.php b/src/Schema/Columns/UUIDColumn.php index b4d0b09..897416e 100644 --- a/src/Schema/Columns/UUIDColumn.php +++ b/src/Schema/Columns/UUIDColumn.php @@ -1,10 +1,4 @@ Port = 3306; + $this->Charset = 'utf8'; $this->ProjectTimeZone = new \DateTimeZone(date_default_timezone_get()); } }