addBehavior('Timestamp'); $this->hasMany( 'MetaTemplateFields', [ 'foreignKey' => 'meta_template_id', 'saveStrategy' => 'replace', 'dependent' => true, 'cascadeCallbacks' => true, ] ); $this->hasOne('MetaTemplateNameDirectory') ->setForeignKey('meta_template_directory_id'); $this->setDisplayField('name'); } public function validationDefault(Validator $validator): Validator { $validator ->notEmptyString('scope') ->notEmptyString('name') ->notEmptyString('namespace') ->notEmptyString('uuid') ->notEmptyString('version') ->notEmptyString('source') ->requirePresence(['scope', 'source', 'version', 'uuid', 'name', 'namespace', 'meta_template_directory_id'], 'create'); return $validator; } public function isStrategyAllowed(string $strategy): bool { return in_array($strategy, MetaTemplatesTable::ALLOWED_STRATEGIES); } /** * Load the templates stored on the disk update and create them in the database without touching at the existing ones * * @param \App\Model\Entity\MetaTemplate $metaTemplate * @return array The update result containing potential errors and the successes */ public function updateAllTemplates(): array { // Create new template found on the disk $newTemplateResult = $this->createAllNewTemplates(); $files_processed = $newTemplateResult['files_processed']; $updatesErrors = $newTemplateResult['update_errors']; $templatesUpdateStatus = $this->getUpdateStatusForTemplates(); // Update all existing templates foreach ($templatesUpdateStatus as $uuid => $templateUpdateStatus) { if (!empty($templateUpdateStatus['existing_template'])) { $metaTemplate = $templateUpdateStatus['existing_template']; $result = $this->update($metaTemplate, null); if ($result['success']) { $files_processed[] = $metaTemplate->uuid; } if (!empty($result['errors'])) { $updatesErrors[] = $result['errors']; } } } $results = [ 'update_errors' => $updatesErrors, 'files_processed' => $files_processed, 'success' => !empty($files_processed), ]; return $results; } /** * Load the templates stored on the disk update and create them in the database without touching at the existing ones * * @return array The update result containing potential errors and the successes */ public function createAllNewTemplates(): array { $updatesErrors = []; $files_processed = []; $templatesOnDisk = $this->readTemplatesFromDisk(); $templatesUpdateStatus = $this->getUpdateStatusForTemplates(); foreach ($templatesOnDisk as $template) { $errors = []; $success = false; $updateStatus = $templatesUpdateStatus[$template['uuid']]; if ($this->isStrategyAllowed(MetaTemplatesTable::UPDATE_STRATEGY_CREATE_NEW) && $updateStatus['new']) { $success = $this->saveNewMetaTemplate($template, $errors); } if ($success) { $files_processed[] = $template['uuid']; } else { $updatesErrors[] = $errors; } } $results = [ 'update_errors' => $updatesErrors, 'files_processed' => $files_processed, 'success' => !empty($files_processed), ]; return $results; } /** * Load the template stored on the disk for the provided meta-template and update it using the optional strategy. * * @param \App\Model\Entity\MetaTemplate $metaTemplate * @param string|null $strategy The strategy to be used when updating templates with conflicts * @return array The update result containing potential errors and the successes */ public function update(\App\Model\Entity\MetaTemplate $metaTemplate, $strategy = null): array { $files_processed = []; $updatesErrors = []; $templateOnDisk = $this->readTemplateFromDisk($metaTemplate->uuid); $templateStatus = $this->getStatusForMetaTemplate($templateOnDisk, $metaTemplate); $updateStatus = $this->computeFullUpdateStatusForMetaTemplate($templateStatus, $metaTemplate); $errors = []; $result = $this->doUpdate($updateStatus, $templateOnDisk, $metaTemplate, $strategy); if ($result['success']) { $files_processed[] = $templateOnDisk['uuid']; } if (!empty($result['errors'])) { $updatesErrors[] = implode(', ', Hash::extract($result['errors'], '{n}.message')); } $results = [ 'update_errors' => $updatesErrors, 'files_processed' => $files_processed, 'success' => !empty($files_processed), ]; return $results; } private function doUpdate(array $updateStatus, array $templateOnDisk, \App\Model\Entity\MetaTemplate $metaTemplate, string $strategy = null): array { $errors = []; $success = false; if ($updateStatus['up-to-date']) { $errors['message'] = __('Meta-template already up-to-date'); $success = true; } else if ($this->isStrategyAllowed(MetaTemplatesTable::UPDATE_STRATEGY_CREATE_NEW) && $updateStatus['is-new']) { $success = $this->saveNewMetaTemplate($templateOnDisk, $errors); } else if ($this->isStrategyAllowed(MetaTemplatesTable::UPDATE_STRATEGY_UPDATE_EXISTING) && $updateStatus['automatically-updateable']) { $success = $this->updateMetaTemplate($metaTemplate, $templateOnDisk, $errors); } else if (!$updateStatus['up-to-date'] && (!is_null($strategy) && !$this->isStrategyAllowed($strategy))) { $errors['message'] = __('Cannot update meta-template, update strategy not allowed'); } else if (!$updateStatus['up-to-date']) { $strategy = is_null($strategy) ? MetaTemplatesTable::DEFAULT_STRATEGY : $strategy; if ($strategy == MetaTemplatesTable::UPDATE_STRATEGY_UPDATE_EXISTING && !$updateStatus['automatically-updateable']) { $strategy = MetaTemplatesTable::UPDATE_STRATEGY_KEEP_BOTH; } $success = $this->updateMetaTemplateWithStrategyRouter($metaTemplate, $templateOnDisk, $strategy, $errors); } else { $errors['message'] = __('Could not update. Something went wrong.'); } return [ 'success' => $success, 'errors' => $errors, ]; } /** * Load the templates stored on the disk update and create the one having the provided UUID in the database * Will do nothing if the UUID is already known * * @param string $uuid * @return array The update result containing potential errors and the successes */ public function createNewTemplate(string $uuid): array { $templateOnDisk = $this->readTemplateFromDisk($uuid); $templateStatus = $this->getUpdateStatusForTemplate($templateOnDisk); $errors = []; $updatesErrors = []; $files_processed = []; $savedMetaTemplate = null; $success = false; if (empty($templateStatus['new'])) { $error['message'] = __('Template UUID already exists'); $success = true; } else if ($this->isStrategyAllowed(MetaTemplatesTable::UPDATE_STRATEGY_CREATE_NEW)) { $success = $this->saveNewMetaTemplate($templateOnDisk, $errors, $savedMetaTemplate); } else { $errors['message'] = __('Could not create template. Something went wrong.'); } if ($success) { $files_processed[] = $templateOnDisk['uuid']; } if (!empty($errors)) { $updatesErrors[] = $errors; } $results = [ 'update_errors' => $updatesErrors, 'files_processed' => $files_processed, 'success' => !empty($files_processed), ]; return $results; } /** * Load the templates stored on the disk and compute their update status. * Only compute the result if an UUID is provided * * @param string|null $template_uuid * @return array */ public function getUpdateStatusForTemplates(): array { $errors = []; $templateUpdatesStatus = []; $templates = $this->readTemplatesFromDisk($errors); foreach ($templates as $template) { $templateUpdatesStatus[$template['uuid']] = $this->getUpdateStatusForTemplate($template); } return $templateUpdatesStatus; } /** * Checks if the template is update-to-date from the provided update status * * @param array $updateStatus * @return boolean */ public function isUpToDate(array $updateStatus): bool { return !empty($updateStatus['up-to-date']) || !empty($updateStatus['new']); } /** * Checks if the template is updateable automatically from the provided update status * * @param array $updateStatus * @return boolean */ public function isAutomaticallyUpdateable(array $updateStatus): bool { return !empty($updateStatus['automatically-updateable']); } /** * Checks if the template is new (and not loaded in the database yet) from the provided update status * * @param array $updateStatus * @return boolean */ public function isNew(array $updateStatus): bool { return $updateStatus['new']; } /** * Checks if the template has no conflicts that would prevent an automatic update from the provided update status * * @param array $updateStatus * @return boolean */ public function hasNoConflict(array $updateStatus): bool { return $this->hasConflict($updateStatus); } /** * Checks if the template has conflict preventing an automatic update from the provided update status * * @param array $updateStatus * @return boolean */ public function hasConflict(array $updateStatus): bool { return empty($updateStatus['automatically-updateable']) && empty($updateStatus['up-to-date']) && empty($updateStatus['new']); } /** * Checks if the metaTemplate can be updated to a newer version loaded in the database * * @param \App\Model\Entity\MetaTemplate $metaTemplate * @return boolean */ public function isUpdateableToExistingMetaTemplate(\App\Model\Entity\MetaTemplate $metaTemplate): bool { $newestTemplate = $this->getNewestVersion($metaTemplate); return !empty($newestTemplate); } /** * Checks if the template can be removed from the database for the provided update status. * A template can be removed if a newer version is already loaded in the database and no meta-fields are using it. * * @param array $updateStatus * @return boolean */ public function isRemovable(array $updateStatus): bool { return !empty($updateStatus['can-be-removed']); } /** * Compute the state from the provided update status and metaTemplate * * @param array $updateStatus * @param \App\Model\Entity\MetaTemplate $metaTemplate * @return array */ public function computeFullUpdateStatusForMetaTemplate(array $updateStatus, \App\Model\Entity\MetaTemplate $metaTemplate): array { return [ 'up-to-date' => $this->isUpToDate($updateStatus), 'automatically-updateable' => $this->isAutomaticallyUpdateable($updateStatus), 'is-new' => $this->isNew($updateStatus), 'has-conflict' => $this->hasConflict($updateStatus), 'to-existing' => $this->isUpdateableToExistingMetaTemplate($metaTemplate), 'can-be-removed' => $this->isRemovable($updateStatus), ]; } /** * Get the update status of meta-templates that are up-to-date in regards to the template stored on the disk. * * @param array|null $updateStatus * @return array The list of update status for up-to-date templates */ public function getUpToDateTemplates($updatesStatus = null): array { $updatesStatus = is_null($updatesStatus) ? $this->getUpdateStatusForTemplates() : $updatesStatus; foreach ($updatesStatus as $uuid => $updateStatus) { if (!$this->isUpToDate($updateStatus)) { unset($updatesStatus[$uuid]); } } return $updatesStatus; } /** * Get the update status of meta-templates that are not up-to-date in regards to the template stored on the disk. * * @param array|null $updateResult * @return array The list of update status for non up-to-date templates */ public function getNotUpToDateTemplates($updatesStatus = null): array { $updatesStatus = is_null($updatesStatus) ? $this->getUpdateStatusForTemplates() : $updatesStatus; foreach ($updatesStatus as $uuid => $updateStatus) { if ($this->isUpToDate($updateStatus)) { unset($updatesStatus[$uuid]); } } return $updatesStatus; } /** * Get the update status of meta-templates that are automatically updateable in regards to the template stored on the disk. * * @param array|null $updateResult * @return array The list of update status for non up-to-date templates */ public function getAutomaticallyUpdateableTemplates($updatesStatus = null): array { $updatesStatus = is_null($updatesStatus) ? $this->getUpdateStatusForTemplates() : $updatesStatus; foreach ($updatesStatus as $uuid => $updateStatus) { if (!$this->isAutomaticallyUpdateable($updateStatus)) { unset($updatesStatus[$uuid]); } } return $updatesStatus; } /** * Get the update status of meta-templates that are new in regards to the template stored on the disk. * * @param array|null $updateResult * @return array The list of update status for new templates */ public function getNewTemplates($updatesStatus = null): array { $updatesStatus = is_null($updatesStatus) ? $this->getUpdateStatusForTemplates() : $updatesStatus; foreach ($updatesStatus as $uuid => $updateStatus) { if (!$this->isNew($updateStatus)) { unset($updatesStatus[$uuid]); } } return $updatesStatus; } /** * Get the update status of meta-templates that have conflict preventing an automatic update in regards to the template stored on the disk. * * @param array|null $updateResult * @return array The list of update status for new templates */ public function getConflictTemplates($updatesStatus = null): array { $updatesStatus = is_null($updatesStatus) ? $this->getUpdateStatusForTemplates() : $updatesStatus; foreach ($updatesStatus as $uuid => $updateStatus) { if (!$this->hasConflict($updateStatus)) { unset($updatesStatus[$uuid]); } } return $updatesStatus; } /** * Get the latest (having the higher version) meta-template loaded in the database for the provided meta-template * * @param \App\Model\Entity\MetaTemplate $metaTemplate * @param boolean $full * @return \App\Model\Entity\MetaTemplate|null */ public function getNewestVersion(\App\Model\Entity\MetaTemplate $metaTemplate, bool $full = false) { $query = $this->find()->where([ 'uuid' => $metaTemplate->uuid, 'id !=' => $metaTemplate->id, 'version >=' => $metaTemplate->version, ]) ->order(['version' => 'DESC']); if ($full) { $query->contain(['MetaTemplateFields']); } $newestTemplate = $query->first(); return $newestTemplate; } /** * Generate and return a query (to be used as a subquery) resolving to the IDs of the latest version of a saved meta-template * * @return \Cake\ORM\Query */ public function genQueryForAllNewestVersionIDs(): \Cake\ORM\Query { /** * SELECT a.id FROM meta_templates a INNER JOIN ( * SELECT uuid, MAX(version) maxVersion FROM meta_templates GROUP BY uuid * ) b on a.uuid = b.uuid AND a.version = b.maxVersion; */ $query = $this->find() ->select([ 'id' ]) ->join([ 't' => [ 'table' => '(SELECT uuid, MAX(version) AS maxVersion FROM meta_templates GROUP BY uuid)', 'type' => 'INNER', 'conditions' => [ 't.uuid = MetaTemplates.uuid', 't.maxVersion = MetaTemplates.version' ], ], ]); return $query; } /** * Get the update status of meta-templates that can be removed. * * @param array|null $updateResult * @return array The list of update status for new templates */ public function getCanBeRemovedTemplates($updatesStatus = null): array { $updatesStatus = is_null($updatesStatus) ? $this->getUpdateStatusForTemplates() : $updatesStatus; foreach ($updatesStatus as $i => $updateStatus) { if (!$this->isRemovable($updateStatus)) { unset($updatesStatus[$i]); } } return $updatesStatus; } /** * Reads all template stored on the disk and parse them * * @param array|null $errors Contains errors while parsing the meta-templates * @return array The parsed meta-templates stored on the disk */ public function readTemplatesFromDisk(&$errors = []): array { if (!is_null($this->templatesOnDisk)) { return $this->templatesOnDisk; } $templates = []; $errors = []; foreach (self::TEMPLATE_PATH as $path) { if (is_dir($path)) { $files = scandir($path); foreach ($files as $k => $file) { if (substr($file, -5) === '.json') { $errorMessage = ''; $template = $this->decodeTemplateFromDisk($path . $file, $errorMessage); if (!empty($template)) { $templates[] = $template; } else { $errors[] = $errorMessage; } } } } } $this->templatesOnDisk = $templates; return $templates; } /** * Read and parse the meta-template stored on disk having the provided UUID * * @param string $uuid * @param string $error Contains the error while parsing the meta-template * @return array|null The meta-template or null if not templates matche the provided UUID */ public function readTemplateFromDisk(string $uuid, &$error = ''): ?array { foreach (self::TEMPLATE_PATH as $path) { if (is_dir($path)) { $files = scandir($path); foreach ($files as $k => $file) { if (substr($file, -5) === '.json') { $errorMessage = ''; $template = $this->decodeTemplateFromDisk($path . $file, $errorMessage); if (!empty($errorMessage)) { $error = $errorMessage; return null; } if (!empty($template) && $template['uuid'] == $uuid) { return $template; } } } } } $error = __('Could not find meta-template with UUID {0}', $uuid); return null; } /** * Read and decode the meta-template located at the provided path * * @param string $filePath * @param string $errorMessage * @return array|null The meta-template or null if there was an error while trying to decode */ public function decodeTemplateFromDisk(string $filePath, &$errorMessage = ''): ?array { $file = new File($filePath, false); if ($file->exists()) { $filename = $file->name(); $content = $file->read(); if (empty($content)) { $errorMessage = __('Could not read template file `{0}`.', $filename); return null; } $metaTemplate = json_decode($content, true); if (empty($metaTemplate)) { $errorMessage = __('Could not load template file `{0}`. Error while decoding the template\'s JSON', $filename); return null; } if (empty($metaTemplate['uuid']) || empty($metaTemplate['version'])) { $errorMessage = __('Could not load template file. Invalid template file. Missing template UUID or version'); return null; } return $metaTemplate; } $errorMessage = __('File does not exists'); return null; } /** * Collect all enties having meta-fields belonging to the provided template * * @param integer $template_id * @param integer|bool $limit The limit of entities to be returned. Pass null to be ignore the limit * @return array List of entities */ public function getEntitiesHavingMetaFieldsFromTemplate(int $metaTemplateId, $limit=10, int &$totalAmount=0): array { $metaTemplate = $this->get($metaTemplateId); $queryParentEntities = $this->MetaTemplateFields->MetaFields->find(); $queryParentEntities ->select(['parent_id']) ->where([ 'meta_template_id' => $metaTemplateId ]) ->group(['parent_id']); $entitiesTable = $this->getTableForMetaTemplateScope($metaTemplate); $entityQuery = $entitiesTable->find() ->where(['id IN' => $queryParentEntities]) ->contain([ 'MetaFields' => [ 'conditions' => [ 'meta_template_id' => $metaTemplateId ] ] ]); if (!is_null($limit)) { $totalAmount = $entityQuery->all()->count(); $entityQuery->limit($limit); } $entities = $entityQuery->all()->toList(); return $entities; } /** * Get the table linked to the meta-template * * @param \App\Model\Entity\MetaTemplate|string $metaTemplate * @return \App\Model\Table\AppTable */ private function getTableForMetaTemplateScope($metaTemplateOrScope): \App\Model\Table\AppTable { if (is_string($metaTemplateOrScope)) { $scope = $metaTemplateOrScope; } else { $scope = $metaTemplateOrScope->scope; } $entitiesClassName = Inflector::camelize(Inflector::pluralize($scope)); $entitiesTable = TableRegistry::getTableLocator()->get($entitiesClassName); return $entitiesTable; } /** * Get the meta-field keyed by their template_id and meta_template_id belonging to the provided entity * * @param integer $entity_id The entity for which the meta-fields belongs to * @param array $conditions Additional conditions to be passed to the meta-fields query * @return array The associated array containing the meta-fields keyed by their meta-template and meta-template-field IDs */ public function getKeyedMetaFieldsForEntity(int $entity_id, array $conditions = []): array { $query = $this->MetaTemplateFields->MetaFields->find(); $query->where(array_merge( $conditions, [ 'MetaFields.parent_id' => $entity_id ] )); $metaFields = $query->all(); $keyedMetaFields = []; foreach ($metaFields as $metaField) { if (empty($keyedMetaFields[$metaField->meta_template_id][$metaField->meta_template_field_id])) { $keyedMetaFields[$metaField->meta_template_id][$metaField->meta_template_field_id] = []; } $keyedMetaFields[$metaField->meta_template_id][$metaField->meta_template_field_id][$metaField->id] = $metaField; } return $keyedMetaFields; } /** * Insert the keyed meta-fields into the provided meta-templates * * @param array $keyedMetaFields An associative array containing the meta-fields keyed by their meta-template and meta-template-field IDs * @param array $metaTemplates List of meta-templates * @return array The list of meta-template with the meta-fields inserted */ public function insertMetaFieldsInMetaTemplates(array $keyedMetaFields, array $metaTemplates): array { $merged = []; foreach ($metaTemplates as $metaTemplate) { $metaTemplate['meta_template_fields'] = Hash::combine($metaTemplate['meta_template_fields'], '{n}.id', '{n}'); $merged[$metaTemplate->id] = $metaTemplate; if (isset($keyedMetaFields[$metaTemplate->id])) { foreach ($metaTemplate->meta_template_fields as $j => $meta_template_field) { if (isset($keyedMetaFields[$metaTemplate->id][$meta_template_field->id])) { $merged[$metaTemplate->id]->meta_template_fields[$j]['metaFields'] = $keyedMetaFields[$metaTemplate->id][$meta_template_field->id]; } else { $merged[$metaTemplate->id]->meta_template_fields[$j]['metaFields'] = []; } } } } return $merged; } /** * Retreive the entity associated for the provided meta-template and id * * @param \App\Model\Entity\MetaTemplate $metaTemplate * @param integer $entity_id * @return \App\Model\Entity\AppModel */ public function getEntity(\App\Model\Entity\MetaTemplate $metaTemplate, int $entity_id): \App\Model\Entity\AppModel { $entitiesTable = $this->getTableForMetaTemplateScope($metaTemplate); $entity = $entitiesTable->get($entity_id, [ 'contain' => 'MetaFields' ]); return $entity; } /** * Collect the unique default template for each scope * * @param string|null $scope * @return array The list of default template */ public function getDefaultTemplatePerScope($scope = null): array { $query = $this->find('list', [ 'keyField' => 'scope', 'valueField' => function ($template) { return $template; } ])->where(['is_default' => true]); if (!empty($scope)) { $query->where(['scope' => $scope]); } return $query->all()->toArray(); } /** * Remove the default flag for all meta-templates belonging to the provided scope * * @param string $scope * @return int the number of updated rows */ public function removeDefaultFlag(string $scope): int { return $this->updateAll( ['is_default' => false], ['scope' => $scope] ); } /** * Check if the provided template can be saved in the database without creating duplicate template in regards to the UUID and version * * @param array $template * @return boolean */ public function canBeSavedWithoutDuplicates(array $template): bool { $query = $this->find()->where([ 'uuid' => $template['uuid'], 'version' => $template['version'], ]); return $query->count() == 0; } /** * Create and save the provided template in the database * * @param array $template The template to be saved * @param array $errors The list of errors that occured during the save process * @param \App\Model\Entity\MetaTemplate $savedMetaTemplate The metaTemplate entity that has just been saved * @return boolean True if the save was successful, False otherwise */ public function saveNewMetaTemplate(array $template, array &$errors = [], \App\Model\Entity\MetaTemplate &$savedMetaTemplate = null): bool { if (!$this->canBeSavedWithoutDuplicates($template)) { $errors[] = new UpdateError(false, __('Could not save the template. A template with this UUID and version already exists'), ['A template with UUID and version already exists']); } $template['meta_template_fields'] = $template['metaFields']; unset($template['metaFields']); $metaTemplate = $this->newEntity($template, [ 'associated' => ['MetaTemplateFields'] ]); $metaTemplateDirectory = $this->MetaTemplateNameDirectory->createFromMetaTemplate($metaTemplate); $metaTemplate->meta_template_directory_id = $metaTemplateDirectory->id; $tmp = $this->save($metaTemplate, [ 'associated' => ['MetaTemplateFields'] ]); if ($tmp === false) { $errors[] = new UpdateError(false, __('Could not save the template.'), $metaTemplate->getErrors()); return false; } $savedMetaTemplate = $tmp; return true; } /** * Update an existing meta-template and save it in the database * * @param \App\Model\Entity\MetaTemplate $metaTemplate The meta-template to update * @param array $template The template to use to update the existing meta-template * @param array $errors * @return boolean True if the save was successful, False otherwise */ public function updateMetaTemplate(\App\Model\Entity\MetaTemplate $metaTemplate, array $template, array &$errors = []): bool { if (!$this->canBeSavedWithoutDuplicates($template)) { $errors[] = new UpdateError(false, __('Could not save the template. A template with this UUID and version already exists'), ['A template with UUID and version already exists']); } if (is_string($metaTemplate)) { $errors[] = new UpdateError(false, $metaTemplate); return false; } $metaTemplate = $this->patchEntity($metaTemplate, $template); foreach ($template['metaFields'] as $newMetaField) { $newMetaField['__patched'] = true; foreach($metaTemplate->meta_template_fields as $i => $oldMetaField) { if ($oldMetaField->field == $newMetaField['field']) { $metaTemplate->meta_template_fields[$i] = $this->MetaTemplateFields->patchEntity($oldMetaField, $newMetaField); continue 2; } } $metaTemplate->meta_template_fields[] = $this->MetaTemplateFields->newEntity($newMetaField); } $metaTemplate->setDirty('meta_template_fields', true); $metaTemplate = $this->save($metaTemplate, [ 'associated' => ['MetaTemplateFields'] ]); foreach ($metaTemplate->meta_template_fields as $savedMetafield) { if (empty($savedMetafield['__patched'])) { $this->MetaTemplateFields->delete($savedMetafield); } } if (empty($metaTemplate)) { $errors[] = new UpdateError(false, __('Could not save the template.'), $metaTemplate->getErrors()); return false; } return true; } /** * Update an existing meta-template with the provided strategy and save it in the database * * @param \App\Model\Entity\MetaTemplate $metaTemplate The meta-template to update * @param array $template The template to use to update the existing meta-template * @param string $strategy The strategy to use when handling update conflicts * @param array $errors * @return boolean True if the save was successful, False otherwise */ public function updateMetaTemplateWithStrategyRouter(\App\Model\Entity\MetaTemplate $metaTemplate, array $template, string $strategy, array &$errors = []): bool { if (!$this->canBeSavedWithoutDuplicates($template)) { $errors[] = new UpdateError(false, __('Could not save the template. A template with this UUID and version already exists'), ['A template with UUID and version already exists']); } if (is_string($metaTemplate)) { $errors[] = new UpdateError(false, $metaTemplate); return false; } if ($strategy == MetaTemplatesTable::UPDATE_STRATEGY_KEEP_BOTH) { $result = $this->executeStrategyKeep($template, $metaTemplate); } else if ($strategy == MetaTemplatesTable::UPDATE_STRATEGY_UPDATE_EXISTING) { $result = $this->updateMetaTemplate($metaTemplate, $template); } else if ($strategy == MetaTemplatesTable::UPDATE_STRATEGY_CREATE_NEW) { $result = $this->executeStrategyCreateNew($template, $metaTemplate); } else if ($strategy == MetaTemplatesTable::UPDATE_STRATEGY_DELETE_ALL) { $result = $this->executeStrategyDeleteAll($template, $metaTemplate); } else { $errors[] = new UpdateError(false, __('Invalid strategy {0}', $strategy)); return false; } if (is_string($result)) { $errors[] = new UpdateError(false, $result); return false; } return true; } /** * Execute the `keep_both` update strategy by creating a new meta-template and moving non-conflicting entities to this one. * Strategy: * - Old template remains untouched * - Create new template * - Migrate all non-conflicting meta-fields for one entity to the new template * - Keep all the conflicting meta-fields for one entity on the old template * * @param array $template * @param \App\Model\Entity\MetaTemplate $metaTemplate * @return bool|string If the new template could be saved or the error message */ public function executeStrategyKeep(array $template, \App\Model\Entity\MetaTemplate $metaTemplate) { if (!$this->canBeSavedWithoutDuplicates($template)) { $errors[] = new UpdateError(false, __('Could not save the template. A template with this UUID and version already exists'), ['A template with UUID and version already exists']); } $conflicts = $this->getMetaTemplateConflictsForMetaTemplate($metaTemplate, $template); $conflictingEntities = Hash::combine($conflicts, '{s}.conflictingEntities.{n}.parent_id', '{s}.conflictingEntities.{n}.parent_id'); $blockingConflict = Hash::extract($conflicts, '{s}.conflicts'); $errors = []; if (empty($blockingConflict)) { // No conflict, everything can be updated without special care $this->updateMetaTemplate($metaTemplate, $template, $errors); return !empty($errors) ? $errors[0] : true; } $entities = $this->getEntitiesHavingMetaFieldsFromTemplate($metaTemplate->id, null); foreach ($entities as $entity) { $conflicts = $this->getMetaFieldsConflictsUnderTemplate($entity['meta_fields'], $template); if (!empty($conflicts)) { $conflictingEntities[$entity->id] = $entity->id; } } if (empty($conflictingEntities)) { $this->updateMetaTemplate($metaTemplate, $template, $errors); return !empty($errors) ? $errors[0] : true; } $template['is_default'] = $metaTemplate->is_default; $template['enabled'] = $metaTemplate->enabled; $metaTemplate->set('enabled', false); if ($metaTemplate->is_default) { $metaTemplate->set('is_default', false); $this->save($metaTemplate); } $savedMetaTemplate = null; $this->saveNewMetaTemplate($template, $errors, $savedMetaTemplate); if (!empty($savedMetaTemplate)) { foreach ($entities as $entity) { if (empty($conflictingEntities[$entity->id])) { // conflicting entities remain untouched $this->supersedeMetaFieldsWithMetaTemplateField($entity['meta_fields'], $savedMetaTemplate); } } } else { return $errors[0]->message; } return true; } public function migrateMetafieldsToNewestTemplate(\App\Model\Entity\MetaTemplate $oldMetaTemplate, \App\Model\Entity\MetaTemplate $newestMetaTemplate, bool $forceMigration): array { $result = [ 'success' => true, 'migrated_count' => 0, 'conflicting_entities' => 0, 'migration_errors' => 0, ]; $entities = $this->getEntitiesHavingMetaFieldsFromTemplate($oldMetaTemplate->id, null); if (empty($entities)) { return $result; } $successfullyMigratedEntities = 0; $migrationErrors = 0; $conflictingEntities = []; foreach ($entities as $entity) { $conflicts = $this->getMetaFieldsConflictsUnderTemplate($entity->meta_fields, $newestMetaTemplate); if (!empty($conflicts)) { $conflictingEntities[] = $entity->id; if (!$forceMigration) { continue; } else { $conflictingMetafieldIDs = Hash::extract($conflicts, '{n}.id'); $metaFieldsToDelete = []; foreach ($entity->meta_fields as $i => $metaField) { if (in_array($metaField->id, $conflictingMetafieldIDs)) { $metaFieldsToDelete[] = $metaField; unset($entity->meta_fields[$i]); } } $this->MetaTemplateFields->MetaFields->unlink($entity, $metaFieldsToDelete); } } $success = $this->supersedeMetaFieldsWithMetaTemplateField($entity->meta_fields, $newestMetaTemplate); if ($success) { $successfullyMigratedEntities += 1; } else { $migrationErrors += 1; } } $result['success'] = $migrationErrors == 0; $result['migrated_count'] = $successfullyMigratedEntities; $result['conflicting_entities'] = count($conflictingEntities); $result['migration_errors'] = $migrationErrors; return $result; } /** * Execute the `delete_all` update strategy by updating the meta-template and deleting all conflicting meta-fields. * Strategy: * - Delete conflicting meta-fields * - Update template to the new version * * @param array $template * @param \App\Model\Entity\MetaTemplate $metaTemplate * @return bool|string If the new template could be saved or the error message */ public function executeStrategyDeleteAll(array $template, \App\Model\Entity\MetaTemplate $metaTemplate) { if (!$this->canBeSavedWithoutDuplicates($template)) { $errors[] = new UpdateError(false, __('Could not save the template. A template with this UUID and version already exists'), ['A template with UUID and version already exists']); } $errors = []; $conflicts = $this->getMetaTemplateConflictsForMetaTemplate($metaTemplate, $template); $blockingConflict = Hash::extract($conflicts, '{s}.conflicts'); if (empty($blockingConflict)) { // No conflict, everything can be updated without special care $this->updateMetaTemplate($metaTemplate, $template, $errors); return !empty($errors) ? $errors[0] : true; } $entities = $this->getEntitiesHavingMetaFieldsFromTemplate($metaTemplate->id, null); foreach ($entities as $entity) { $conflicts = $this->getMetaFieldsConflictsUnderTemplate($entity['meta_fields'], $template); $this->MetaTemplateFields->MetaFields->unlink($entity, $conflicts); } $this->updateMetaTemplate($metaTemplate, $template, $errors); return !empty($errors) ? $errors[0] : true; } /** * Execute the `create_new` update strategy by creating a new meta-template * Strategy: * - Create a new meta-template * - Make the new meta-template `default` and `enabled` if previous template had these states * - Turn of these states on the old meta-template * * @param array $template * @param \App\Model\Entity\MetaTemplate $metaTemplate * @return bool|string If the new template could be saved or the error message */ public function executeStrategyCreateNew(array $template, \App\Model\Entity\MetaTemplate $metaTemplate) { if (!$this->canBeSavedWithoutDuplicates($template)) { $errors[] = new UpdateError(false, __('Could not save the template. A template with this UUID and version already exists'), ['A template with UUID and version already exists']); } $errors = []; $template['is_default'] = $metaTemplate->is_default; $template['enabled'] = $metaTemplate->enabled; $savedMetaTemplate = null; $success = $this->saveNewMetaTemplate($template, $errors, $savedMetaTemplate); if ($success) { if ($metaTemplate->is_default) { $metaTemplate->set('is_default', false); $metaTemplate->set('enabled', false); $this->save($metaTemplate); } } return !empty($errors) ? $errors[0] : true; } /** * Supersede a meta-fields's meta-template-field with the provided one. * * @param array $metaFields * @param \App\Model\Entity\MetaTemplateField $savedMetaTemplateField * @return bool True if the replacement was a success, False otherwise */ public function supersedeMetaFieldsWithMetaTemplateField(array $metaFields, \App\Model\Entity\MetaTemplate $savedMetaTemplate): bool { $savedMetaTemplateFieldByName = Hash::combine($savedMetaTemplate['meta_template_fields'], '{n}.field', '{n}'); foreach ($metaFields as $i => $metaField) { $savedMetaTemplateField = $savedMetaTemplateFieldByName[$metaField->field]; $metaField->set('meta_template_id', $savedMetaTemplateField->meta_template_id); $metaField->set('meta_template_field_id', $savedMetaTemplateField->id); } $entities = $this->MetaTemplateFields->MetaFields->saveMany($metaFields); return !empty($entities); } /** * Compute the validity of the provided meta-fields under the provided meta-template * * @param \App\Model\Entity\MetaField[] $metaFields * @param array|\App\Model\Entity\MetaTemplate $template * @return \App\Model\Entity\MetaField[] The list of conflicting meta-fields under the provided template */ public function getMetaFieldsConflictsUnderTemplate(array $metaFields, $template): array { if (!is_array($template) && get_class($template) == 'App\Model\Entity\MetaTemplate') { $metaTemplateFields = $template->meta_template_fields; $existingMetaTemplate = true; } else { $metaTemplateFields = $template['metaFields']; } $conflicting = []; $metaTemplateFieldByName = []; foreach ($metaTemplateFields as $metaTemplateField) { if (!is_array($template)) { $metaTemplateField = $metaTemplateField->toArray(); } $metaTemplateFieldByName[$metaTemplateField['field']] = $this->MetaTemplateFields->newEntity($metaTemplateField); } foreach ($metaFields as $metaField) { if (empty($metaTemplateFieldByName[$metaField->field])) { // Meta-field was removed from the template $isValid = false; } else { $isValid = $this->MetaTemplateFields->MetaFields->isValidMetaFieldForMetaTemplateField( $metaField->value, $metaTemplateFieldByName[$metaField->field] ); } if ($isValid !== true) { $conflicting[] = $metaField; } } return $conflicting; } /** * Compute the potential conflict that would be introduced by updating an existing meta-template-field with the provided one. * This will go through all instanciation of the existing meta-template-field and checking their validity against the provided one. * * @param \App\Model\Entity\MetaTemplateField $metaTemplateField * @param array $templateField * @return array */ public function computeExistingMetaTemplateFieldConflictForMetaTemplateField(\App\Model\Entity\MetaTemplateField $metaTemplateField, array $templateField, string $scope): array { $result = [ 'automatically-updateable' => true, 'conflicts' => [], 'conflictingEntities' => [], ]; if ($metaTemplateField->multiple && $templateField['multiple'] == false) { // Field is no longer multiple $query = $this->MetaTemplateFields->MetaFields->find(); $query ->enableHydration(false) ->select([ 'parent_id', 'meta_template_field_id', 'count' => $query->func()->count('meta_template_field_id'), ]) ->where([ 'meta_template_field_id' => $metaTemplateField->id, ]) ->group(['parent_id']) ->having(['count >' => 1]); $conflictingStatus = $query->all()->toList(); if (!empty($conflictingStatus)) { $result['automatically-updateable'] = false; $result['conflicts'][] = __('This field is no longer multiple and is being that way'); $result['conflictingEntities'] = Hash::extract($conflictingStatus, '{n}.parent_id'); } } if ( (!empty($templateField['regex']) && $templateField['regex'] != $metaTemplateField->regex) || !empty($templateField['values_list']) ) { $entities = $this->getEntitiesForMetaTemplateField($scope, $metaTemplateField->id, true); $conflictingEntities = []; foreach ($entities as $entity) { foreach ($entity['meta_fields'] as $metaField) { $isValid = $this->MetaTemplateFields->MetaFields->isValidMetaFieldForMetaTemplateField( $metaField->value, $templateField ); if ($isValid !== true) { $conflictingEntities[] = [ 'parent_id' => $entity->id, 'meta_template_field_id' => $metaTemplateField->id, ]; break; } } } if (!empty($conflictingEntities)) { $result['automatically-updateable'] = $result['automatically-updateable'] && false; $result['conflicts'][] = __('This field is instantiated with values not passing the validation anymore.'); $result['conflictingEntities'] = array_merge($result['conflictingEntities'], $conflictingEntities); } } return $result; } /** * Return all entities having the meta-fields using the provided meta-template-field. * * @param string $scope * @param integer $metaTemplateFieldID The ID of the matching meta-template-field * @param boolean $includeMatchingMetafields Should the entities also include the matching meta-fields * @return array */ private function getEntitiesForMetaTemplateField(string $scope, int $metaTemplateFieldID, bool $includeMatchingMetafields=true): array { $entitiesTable = $this->getTableForMetaTemplateScope($scope); $entitiesWithMetaFieldQuery = $this->MetaTemplateFields->MetaFields->find(); $entitiesWithMetaFieldQuery ->enableHydration(false) ->select([ 'parent_id', ]) ->where([ 'meta_template_field_id' => $metaTemplateFieldID, ]); $entitiesQuery = $entitiesTable->find() ->where(['id IN' => $entitiesWithMetaFieldQuery]); if ($includeMatchingMetafields) { $entitiesQuery->contain([ 'MetaFields' => [ 'conditions' => [ 'MetaFields.meta_template_field_id' => $metaTemplateFieldID, ] ] ]); } return $entitiesQuery->all()->toList(); } /** * Check the conflict that would be introduced if the metaTemplate would be updated to the provided template * * @param \App\Model\Entity\MetaTemplate $metaTemplate * @param \App\Model\Entity\MetaTemplate|array $template * @return array */ public function getMetaTemplateConflictsForMetaTemplate(\App\Model\Entity\MetaTemplate $metaTemplate, $template): array { $templateMetaFields = []; if (!is_array($template) && get_class($template) == 'App\Model\Entity\MetaTemplate') { $templateMetaFields = $template->meta_template_fields; } else { $templateMetaFields = $template['metaFields']; } $conflicts = []; $existingMetaTemplateFields = Hash::combine($metaTemplate->toArray(), 'meta_template_fields.{n}.field', 'meta_template_fields.{n}'); foreach ($templateMetaFields as $newMetaField) { foreach ($metaTemplate->meta_template_fields as $metaField) { if ($newMetaField['field'] == $metaField->field) { unset($existingMetaTemplateFields[$metaField->field]); $metaFieldArray = !is_array($newMetaField) && get_class($newMetaField) == 'App\Model\Entity\MetaTemplateField' ? $newMetaField->toArray() : $newMetaField; $templateConflictsForMetaField = $this->computeExistingMetaTemplateFieldConflictForMetaTemplateField($metaField, $metaFieldArray, $metaTemplate->scope); if (!$templateConflictsForMetaField['automatically-updateable']) { $conflicts[$metaField->field] = $templateConflictsForMetaField; $conflicts[$metaField->field]['existing_meta_template_field'] = $metaField; $conflicts[$metaField->field]['existing_meta_template_field']['conflicts'] = $templateConflictsForMetaField['conflicts']; } } } } if (!empty($existingMetaTemplateFields)) { foreach ($existingMetaTemplateFields as $metaTemplateField) { $query = $this->MetaTemplateFields->MetaFields->find(); $query ->enableHydration(false) ->select([ 'parent_id', 'meta_template_field_id', ]) ->where([ 'meta_template_field_id' => $metaTemplateField['id'], ]) ->group(['parent_id']); $entityWithMetafieldToBeRemoved = $query->all()->toList(); $conflicts[$metaTemplateField['field']] = [ 'automatically-updateable' => false, 'conflicts' => [__('This field is intended to be removed')], 'conflictingEntities' => $entityWithMetafieldToBeRemoved, ]; } } return $conflicts; } /** * Get update status for the latest meta-template in the database for the provided template * * @param array $template * @param \App\Model\Entity\MetaTemplate $metaTemplate $metaTemplate * @return array */ public function getUpdateStatusForTemplate(array $template): array { $updateStatus = [ 'new' => true, 'up-to-date' => false, 'automatically-updateable' => false, 'conflicts' => [], 'template' => $template, ]; $query = $this->find() ->contain('MetaTemplateFields') ->where([ 'uuid' => $template['uuid'], ]) ->order(['version' => 'DESC']); $metaTemplate = $query->first(); if (!empty($metaTemplate)) { $updateStatus = array_merge( $updateStatus, $this->getStatusForMetaTemplate($template, $metaTemplate) ); } return $updateStatus; } /** * Get update status for the meta-template stored in the database and the provided template * * @param array $template * @param \App\Model\Entity\MetaTemplate $metaTemplate * @return array */ public function getStatusForMetaTemplate(array $template, \App\Model\Entity\MetaTemplate $metaTemplate): array { $updateStatus = []; $updateStatus['existing_template'] = $metaTemplate; $updateStatus['current_version'] = $metaTemplate->version; $updateStatus['next_version'] = $template['version']; $updateStatus['new'] = false; $updateStatus['automatically-updateable'] = false; $updateStatus['conflicts'] = []; if (empty($template)) { $updateStatus['up-to-date'] = false; $updateStatus['automatically-updateable'] = false; $updateStatus['can-be-removed'] = false; return $updateStatus; } if (intval($metaTemplate->version) >= intval($template['version'])) { $updateStatus['up-to-date'] = true; $updateStatus['conflicts'][] = __('Could not update the template. Local version is equal or newer.'); return $updateStatus; } $conflicts = $this->getMetaTemplateConflictsForMetaTemplate($metaTemplate, $template); if (!empty($conflicts)) { $updateStatus['conflicts'] = $conflicts; $updateStatus['automatically-updateable'] = false; $emptySum = 0; foreach ($conflicts as $fieldname => $fieldStatus) { if (!empty($fieldStatus['conflictingEntities'])) { break; } $emptySum += 1; } if ($emptySum == count($conflicts)) { $updateStatus['automatically-updateable'] = true; } } else { $updateStatus['automatically-updateable'] = true; } $updateStatus['meta_field_amount'] = $this->MetaTemplateFields->MetaFields->find()->where(['meta_template_id' => $metaTemplate->id])->count(); $updateStatus['can-be-removed'] = empty($updateStatus['meta_field_amount']) && empty($updateStatus['to-existing']); return $updateStatus; } /** * Massages the meta-fields of an entity based on the input * - If the keyed ID of the input meta-field is new, a new meta-field entity is created * - If the input meta-field's value is empty for an existing meta-field, the existing meta-field is marked as to be deleted * - If the input meta-field already exists, patch the entity and attach the validation errors * * @param \App\Model\Entity\AppModel $entity * @param array $input * @param \App\Model\Entity\MetaTemplate $metaTemplate * @return array An array containing the entity with its massaged meta-fields and the meta-fields that should be deleted */ public function massageMetaFieldsBeforeSave(\App\Model\Entity\AppModel $entity, array $input, \App\Model\Entity\MetaTemplate $metaTemplate): array { $metaFieldsTable = $this->MetaTemplateFields->MetaFields; $className = Inflector::camelize(Inflector::pluralize($metaTemplate->scope)); $entityTable = TableRegistry::getTableLocator()->get($className); $metaFieldsIndex = []; if (!empty($entity->meta_fields)) { foreach ($entity->meta_fields as $i => $metaField) { $metaFieldsIndex[$metaField->id] = $i; } } else { $entity->meta_fields = []; } $metaFieldsToDelete = []; foreach ($input['MetaTemplates'] as $template_id => $template) { foreach ($template['meta_template_fields'] as $meta_template_field_id => $meta_template_field) { $rawMetaTemplateField = $metaTemplate->meta_template_fields[$meta_template_field_id]; foreach ($meta_template_field['metaFields'] as $meta_field_id => $meta_field) { if ($meta_field_id == 'new') { // create new meta_field $new_meta_fields = $meta_field; foreach ($new_meta_fields as $new_value) { if (!empty($new_value)) { $metaField = $metaFieldsTable->newEmptyEntity(); $metaFieldsTable->patchEntity($metaField, [ 'value' => $new_value, 'scope' => $entityTable->getBehavior('MetaFields')->getScope(), 'field' => $rawMetaTemplateField->field, 'meta_template_id' => $rawMetaTemplateField->meta_template_id, 'meta_template_field_id' => $rawMetaTemplateField->id, 'parent_id' => $entity->id, 'uuid' => Text::uuid(), ]); $entity->meta_fields[] = $metaField; $entity->MetaTemplates[$template_id]->meta_template_fields[$meta_template_field_id]->metaFields[] = $metaField; } } } else { $new_value = $meta_field['value']; if (!empty($new_value)) { // update meta_field and attach validation errors if (!empty($metaFieldsIndex[$meta_field_id])) { $index = $metaFieldsIndex[$meta_field_id]; $metaFieldsTable->patchEntity($entity->meta_fields[$index], [ 'value' => $new_value, 'meta_template_field_id' => $rawMetaTemplateField->id ], ['value']); $metaFieldsTable->patchEntity( $entity->MetaTemplates[$template_id]->meta_template_fields[$meta_template_field_id]->metaFields[$meta_field_id], ['value' => $new_value, 'meta_template_field_id' => $rawMetaTemplateField->id], ['value'] ); } else { // metafield comes from a second POST where the temporary entity has already been created $metaField = $metaFieldsTable->newEmptyEntity(); $metaFieldsTable->patchEntity($metaField, [ 'value' => $new_value, 'scope' => $entityTable->getBehavior('MetaFields')->getScope(), // get scope from behavior 'field' => $rawMetaTemplateField->field, 'meta_template_id' => $rawMetaTemplateField->meta_template_id, 'meta_template_field_id' => $rawMetaTemplateField->id, 'parent_id' => $entity->id, 'uuid' => Text::uuid(), ]); $entity->meta_fields[] = $metaField; $entity->MetaTemplates[$template_id]->meta_template_fields[$meta_template_field_id]->metaFields[] = $metaField; } } else { // Metafield value is empty, indicating the field should be removed $index = $metaFieldsIndex[$meta_field_id]; $metaFieldsToDelete[] = $entity->meta_fields[$index]; unset($entity->meta_fields[$index]); unset($entity->MetaTemplates[$template_id]->meta_template_fields[$meta_template_field_id]->metaFields[$meta_field_id]); } } } } } $entity->setDirty('meta_fields', true); return ['entity' => $entity, 'metafields_to_delete' => $metaFieldsToDelete]; } } class UpdateError { public $success; public $message = ''; public $errors = []; public function __construct($success = false, $message = '', $errors = []) { $this->success = $success; $this->message = $message; $this->errors = $errors; } }