Source of file FixtureService.php
Size: 27,461 Bytes - Last Modified: 2021-12-24T06:39:27+00:00
/var/www/docs.ssmods.com/process/src/src/Service/FixtureService.php
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759 | <?php namespace ChrisPenny\DataObjectToFixture\Service; use ChrisPenny\DataObjectToFixture\Helper\FluentHelper; use ChrisPenny\DataObjectToFixture\Helper\KahnSorter; use ChrisPenny\DataObjectToFixture\Manifest\FixtureManifest; use ChrisPenny\DataObjectToFixture\Manifest\RelationshipManifest; use ChrisPenny\DataObjectToFixture\ORM\Group; use ChrisPenny\DataObjectToFixture\ORM\Record; use DNADesign\Elemental\Models\ElementalArea; use Exception; use SilverStripe\Core\Config\Config; use SilverStripe\Core\Injector\Injectable; use SilverStripe\ORM\DataList; use SilverStripe\ORM\DataObject; use SilverStripe\ORM\HasManyList; use SilverStripe\ORM\RelationList; use Symfony\Component\Yaml\Yaml; use TractorCow\Fluent\Extension\FluentExtension; use TractorCow\Fluent\Model\Locale; use TractorCow\Fluent\State\FluentState; class FixtureService { use Injectable; /** * @var FixtureManifest */ private $fixtureManifest; /** * @var RelationshipManifest */ private $relationshipManifest; /** * @var bool */ private $validated = false; /** * @var bool */ private $organised = false; /** * @var string[] */ private $warnings = []; /** * @var int */ private $allowedDepth = null; public function __construct() { $this->fixtureManifest = new FixtureManifest(); $this->relationshipManifest = new RelationshipManifest(); } /** * @param DataObject $dataObject * @param int $currentDepth (for internal use) * @return FixtureService * @throws Exception */ public function addDataObject(DataObject $dataObject, int $currentDepth = 0): FixtureService { // Check isInDB() rather than exists(), as exists() has additional checks for (eg) Files if (!$dataObject->isInDB()) { throw new Exception('Your DataObject must be in the DB'); } $currentDepth += 1; // Any time we add a new DataObject, we need to set validated back to false. $this->validated = false; $this->organised = false; // Find or create a record based on the DataObject you want to add. $record = $this->findOrCreateRecordByClassNameID($dataObject->ClassName, $dataObject->ID); // This addDataObject() method gets called many times as we try to build out the structure of related // DataObjects. It's quite likely that the we will come across the same record multiple times. We only need // to add it once. if (!$record->isNew()) { return $this; } $group = $this->fixtureManifest->getGroupByClassName($dataObject->ClassName); if ($group === null) { throw new Exception(sprintf('Group for class should have been available: %s', $dataObject->ClassName)); } // Add this record to our relationship manifest $this->relationshipManifest->addGroup($group); // Add the standard DB fields for this record $this->addDataObjectDBFields($dataObject); // If the DataObject has Fluent applied, then we also need to add Localised fields. if ($dataObject->hasExtension(FluentExtension::class)) { $this->addDataObjectLocalisedFields($dataObject, $currentDepth); } if ($this->getAllowedDepth() !== null && $currentDepth > $this->getAllowedDepth()) { return $this; } // Add direct relationships. $this->addDataObjectHasOneFields($dataObject, $currentDepth); // Add belongs to relationships. $this->addDataObjectBelongsToFields($dataObject, $currentDepth); // has_many fields will include any relationships that you're created using many_many "through". $this->addDataObjectHasManyFields($dataObject, $currentDepth); // many_many relationships without a "through" object are not supported. Add warning for any relationships // we find like that. $this->addDataObjectManyManyFieldWarnings($dataObject, $currentDepth); return $this; } /** * @return string * @throws Exception */ public function outputFixture(): string { // One last thing we need to do before we output this, is make sure, if we're using Fluent, that we've added // each of our Locales to the fixture with the highest priority possible. if (!class_exists(Locale::class)) { return Yaml::dump($this->toArray(), 3); } // We don't have any Locales created, so there is nothing for us to add here. if (Locale::get()->count() === 0) { return Yaml::dump($this->toArray(), 3); } // Find/create a Group for Locale so that we can set it as our highest priority. $group = $this->findOrCreateGroupByClassName(Locale::class); // Only add the Locale Records if this Group was/is new. if ($group->isNew()) { $this->relationshipManifest->addGroup($group); /** @var DataList|Locale[] $locales */ $locales = Locale::get(); // Add all Locale Records. foreach ($locales as $locale) { $this->addDataObject($locale); } } // Make sure our groups are organised in the best order that we can figure out. $this->validateRelationships(); return Yaml::dump($this->toArray(), 3); } /** * @return string[] */ public function getWarnings(): array { // Make sure this is done before returning our warnings. $this->validateRelationships(); return $this->warnings; } /** * @return int */ public function getAllowedDepth(): ?int { return $this->allowedDepth; } /** * @param int $allowedDepth * @return FixtureService */ public function setAllowedDepth(int $allowedDepth = null): FixtureService { if ($allowedDepth === 0) { $this->addWarning('You set an allowed depth of 0. We have assumed you meant 1.'); $allowedDepth = 1; } $this->allowedDepth = $allowedDepth; return $this; } /** * @return array */ protected function toArray(): array { $toArrayGroups = []; foreach ($this->relationshipManifest->getPrioritisedOrder() as $className) { $group = $this->fixtureManifest->getGroupByClassName($className); if (!$group) { continue; } $records = $group->toArray(); if (count($records) === 0) { $this->addWarning(sprintf( 'Fixture output: No records were found for Group/ClassName "%s". You might need to check that you' . ' do not have any relationships pointing to this Group/ClassName.', $group->getClassName(), )); continue; } $toArrayGroups[$group->getClassName()] = $records; } return $toArrayGroups; } /** * @param DataObject $dataObject * @throws Exception */ protected function addDataObjectDBFields(DataObject $dataObject): void { $record = $this->fixtureManifest->getRecordByClassNameID($dataObject->ClassName, $dataObject->ID); if ($record === null) { throw new Exception( sprintf('Unable to find Record "%s" in Group "%s"', $dataObject->ID, $dataObject->ClassName) ); } $dbFields = $dataObject->config()->get('db'); if (!is_array($dbFields)) { return; } foreach ($dbFields as $fieldName => $fieldType) { // DB fields are pretty simple key => value. $value = $dataObject->relField($fieldName); $record->addFieldValue($fieldName, $value); } } /** * @param DataObject $dataObject * @param int $currentDepth (for internal use) * @throws Exception */ protected function addDataObjectHasOneFields(DataObject $dataObject, int $currentDepth = 0): void { $group = $this->fixtureManifest->getGroupByClassName($dataObject->ClassName); if ($group === null) { throw new Exception(sprintf('Unable to find Group "%s"', $dataObject->ClassName)); } $record = $group->getRecordByID($dataObject->ID); if ($group === null) { throw new Exception( sprintf('Unable to find Record "%s" in Group "%s"', $dataObject->ID, $dataObject->ClassName) ); } /** @var array $hasOneRelationships */ $hasOneRelationships = $dataObject->config()->get('has_one'); if (!is_array($hasOneRelationships)) { return; } foreach ($hasOneRelationships as $fieldName => $relationClassName) { $relationFieldName = sprintf('%sID', $fieldName); $fieldClassNameMap = $dataObject->config()->get('field_classname_map'); if ($fieldClassNameMap !== null && array_key_exists($relationFieldName, $fieldClassNameMap)) { $relationClassName = $dataObject->relField($fieldClassNameMap[$relationFieldName]); } // This class has requested that it not be included in relationship maps. $excludeClass = Config::inst()->get($relationClassName, 'exclude_from_fixture_relationships'); if ($excludeClass) { continue; } $excludeRelationship = $this->relationshipManifest->shouldExcludeRelationship( $dataObject->ClassName, $fieldName ); if ($excludeRelationship) { continue; } // If there is no value, then, we have a relationship field, but no relationship active. if (!$dataObject->hasValue($relationFieldName)) { continue; } // We expect this value to be an ID for a related object. if (!is_numeric($dataObject->{$relationFieldName})) { continue; } $relatedObjectID = (int) $dataObject->{$relationFieldName}; // We cannot query a DataObject if ($relationClassName == DataObject::class) { continue; } $relatedObject = DataObject::get($relationClassName)->byID($relatedObjectID); // We expect the relationship to be a DataObject. if (!$relatedObject instanceof DataObject) { $this->addWarning(sprintf( 'Related Object "%s" found on "%s" was not a DataObject', $relationFieldName, $dataObject->ClassName )); continue; } // @todo: this method currently returns false as belongs_to is not supported in fixtures atm. // Don't add the relationship here, we'll add it as part of the belongs to relationship additions. if ($this->hasBelongsToRelationship($relatedObject, $dataObject->ClassName, $fieldName)) { continue; } $relationshipValue = sprintf('=>%s.%s', $relatedObject->ClassName, $relatedObject->ID); // Add the relationship field to our current Record. $record->addFieldValue($relationFieldName, $relationshipValue); // Add the related DataObject. $this->addDataObject($relatedObject, $currentDepth); // Find the Group for the DataObject that we should have just added. $relatedGroup = $this->fixtureManifest->getGroupByClassName($relatedObject->ClassName); if ($relatedGroup === null) { throw new Exception(sprintf('Unable to find Group "%s"', $relatedObject->ClassName)); } // Add a relationship map for these Groups. $this->relationshipManifest->addRelationshipFromTo($group, $relatedGroup); } } /** * @param DataObject $dataObject * @param int $currentDepth (for internal use) * @throws Exception */ protected function addDataObjectBelongsToFields(DataObject $dataObject, int $currentDepth = 0): void { // belongs_to fixture definitions don't appear to be support currently. This is how we can eventually solve // looping relationships though... return; } /** * @param DataObject $dataObject * @param string $fromObjectClassName * @param string $fromRelationship * @return bool */ protected function hasBelongsToRelationship( DataObject $dataObject, string $fromObjectClassName, string $fromRelationship ): bool { // Belongs to fixture definitions don't appear to be support currently. This is how we can eventually solve // looping relationships though... return false; } /** * @param DataObject $dataObject * @param int $currentDepth (for internal use) * @throws Exception */ protected function addDataObjectHasManyFields(DataObject $dataObject, int $currentDepth = 0): void { /** @var array $hasManyRelationships */ $hasManyRelationships = $dataObject->config()->get('has_many'); if (!is_array($hasManyRelationships)) { return; } $schema = $dataObject->getSchema(); foreach ($hasManyRelationships as $relationFieldName => $relationClassName) { // Relationships are sometimes defined as ClassName.FieldName. Drop the .FieldName $cleanRelationshipClassName = strtok($relationClassName, '.'); // Use Schema to make sure that this relationship has a reverse has_one created. This will throw an // Exception if there isn't (SilverStripe always expects you to have it). $schema->getRemoteJoinField($dataObject->ClassName, $relationFieldName, 'has_many'); // This class has requested that it not be included in relationship maps. $excludeClass = Config::inst()->get($cleanRelationshipClassName, 'exclude_from_fixture_relationships'); if ($excludeClass) { continue; } $excludeRelationship = $this->relationshipManifest->shouldExcludeRelationship( $dataObject->ClassName, $relationFieldName ); if ($excludeRelationship) { continue; } // If we have the correct relationship mapping (a "has_one" relationship on the object in the "has_many"), // then we can simply add each of these records and let the "has_one" be added by addRecordHasOneFields(). foreach ($dataObject->relField($relationFieldName) as $relatedObject) { // Add the related DataObject. Recursion starts. $this->addDataObject($relatedObject, $currentDepth); } } } /** * @param DataObject $dataObject * @param int $currentDepth (for internal use) */ protected function addDataObjectManyManyFieldWarnings(DataObject $dataObject, int $currentDepth = 0): void { /** @var array $manyManyRelationships */ $manyManyRelationships = $dataObject->config()->get('many_many'); if (!is_array($manyManyRelationships)) { return; } if (count($manyManyRelationships) === 0) { return; } foreach ($manyManyRelationships as $relationshipName => $relationshipValue) { // This many_many relationship has a "through" object, so we're all good. if (is_array($relationshipValue) && array_key_exists('through', $relationshipValue)) { continue; } // This many_many relationship is being excluded anyhow, so we're also all good here. $exclude = Config::inst()->get($relationshipValue, 'exclude_from_fixture_relationships'); if ($exclude) { continue; } // Ok, so, you're probably expecting the fixture to include this relationship... but it won't. Here's your // warning. $this->addWarning(sprintf( 'many_many relationships without a "through" are not supported. No yml generated for ' . 'relationship: %s::%s()', $dataObject->ClassName, $relationshipName )); } } /** * @param DataObject|FluentExtension $dataObject * @param int $currentDepth (for internal use) * @throws Exception */ protected function addDataObjectLocalisedFields(DataObject $dataObject, int $currentDepth = 0): void { $localeCodes = FluentHelper::getLocaleCodesByObjectInstance($dataObject); $localisedTables = $dataObject->getLocalisedTables(); // There are no Localisations for us to export for this DataObject. if (count($localeCodes) === 0) { return; } // Somehow... there aren't any Localised tables for this DataObject? if (count($localisedTables) === 0) { return; } // In order to get the Localised data for this DataObject, we must re-fetch it while we have a FluentState set. $className = $dataObject->ClassName; $id = $dataObject->ID; // We can't add related DataObject from within the FluentState - if we do that, we'll be adding the Localised // record as if it was the base record. $relatedDataObjects = []; foreach ($localeCodes as $locale) { FluentState::singleton()->withState( function (FluentState $state) use ( $localisedTables, $relatedDataObjects, $locale, $className, $id, $currentDepth ): void { $state->setLocale($locale); // Re-fetch our DataObject. This time it should be Localised with all of the specific content that we // need to export for this Locale. $localisedDataObject = DataObject::get($className)->byID($id); if ($localisedDataObject === null) { // Let's not break the entire process because of this, but we should flag it up as a warning. $this->addWarning(sprintf( 'DataObject Localisation could not be found for Class: %s | ID: %s | Locale %s', $className, $id, $locale )); return; } $localisedID = sprintf('%s%s', $id, $locale); foreach ($localisedTables as $localisedTable => $localisedFields) { $localisedTableName = sprintf('%s_%s', $localisedTable, FluentExtension::SUFFIX); $record = $this->findOrCreateRecordByClassNameID($localisedTableName, $localisedID); $record->addFieldValue('RecordID', sprintf('=>%s.%s', $className, $id)); $record->addFieldValue('Locale', $locale); foreach ($localisedFields as $localisedField) { $isIDField = (substr($localisedField, -2) === 'ID'); if ($isIDField) { $relationshipName = substr($localisedField, 0, -2); $fieldValue = $localisedDataObject->relField($relationshipName); } else { $fieldValue = $localisedDataObject->relField($localisedField); } // Check if this is a "regular" field value, if it is then add it and continue if (!$fieldValue instanceof DataObject && !$fieldValue instanceof RelationList) { $record->addFieldValue($localisedField, $fieldValue); continue; } // Remaining field values are going to be relational values, so we need to check whether or // not we're already at our max allowed depth before adding those relationships if ($this->getAllowedDepth() !== null && $currentDepth > $this->getAllowedDepth()) { continue; } if ($fieldValue instanceof DataObject) { $relatedDataObjects[] = $fieldValue; $relationshipValue = sprintf('=>%s.%s', $fieldValue->ClassName, $fieldValue->ID); // Add the relationship field to our current Record. $record->addFieldValue($localisedField, $relationshipValue); continue; } if ($fieldValue instanceof HasManyList) { foreach ($fieldValue as $relatedDataObject) { $relatedDataObjects[] = $relatedDataObject; } } // No other field types are supported (EG: ManyManyList) } } } ); } foreach ($relatedDataObjects as $relatedDataObject) { $this->addDataObject($relatedDataObject, $currentDepth); } } /** * @param string $className * @return Group * @throws Exception */ protected function findOrCreateGroupByClassName(string $className): Group { $group = $this->fixtureManifest->getGroupByClassName($className); if ($group !== null) { return $group; } $group = Group::create($className); $this->fixtureManifest->addGroup($group); return $group; } /** * @param string $className * @param string|int $id * @return Record * @throws Exception */ protected function findOrCreateRecordByClassNameID(string $className, $id): Record { $group = $this->findOrCreateGroupByClassName($className); // The Group should have been available. If it isn't, that's a paddlin. if ($group === null) { throw new Exception(sprintf('Group "%s" should have been available', $className)); } $record = $group->getRecordByID($id); // If the Record already exists, then we can just return it. if ($record !== null) { return $record; } // Create and add the new Record, and then return it. $record = Record::create($id); $group->addRecord($record); return $record; } protected function validateRelationships(): void { // We can skip this if no extra DataObjects were added since the last time. if ($this->validated) { return; } foreach ($this->relationshipManifest->getRelationships() as $fromClass => $toClasses) { if (count($toClasses) === 0) { continue; } $parentage = [$fromClass]; $this->removeLoopingRelationships($parentage, $toClasses); } $this->validated = true; } /** * @param array $parentage * @param array $toClasses */ protected function removeLoopingRelationships(array $parentage, array $toClasses) { $relationships = $this->relationshipManifest->getRelationships(); foreach ($toClasses as $toClass) { // This To Class does not have any additional relationships that we need to consider. if (!array_key_exists($toClass, $relationships)) { continue; } // Grab the To Classes for this child relationship. $childToClass = $relationships[$toClass]; // Sanity check, but we should only have keys when there are relationships. In any case, if there are no // relationships for this Class, then there is nothing for us to do here. if (count($childToClass) === 0) { continue; } // Check to see if there is any intersection between this Classes relationships, and the parentage tree // that we have drawn so far. $loopingRelationships = array_intersect($parentage, $childToClass); // If we find an intersection, then we need to remove them. The relationships are removed from the // manifest itself. if (count($loopingRelationships) > 0) { // We can keep the original relationship, but we'll remove the one that loops back to the original. foreach ($loopingRelationships as $loopingRelationship) { $this->relationshipManifest->removeRelationship($toClass, $loopingRelationship); $this->addWarning(sprintf( 'A relationships was removed between "%s" and "%s". This occurs if we have detected a' . ' loop . Until belongs_to relationships are supported in fixtures, you might not be able' . ' to rely on fixtures generated to have the appropriate priority order. You might want to' . ' consider adding one of these relationships to `excluded_fixture_relationships`.', $loopingRelationship, $toClass )); // Find the Group for the relationship that has a loop. $group = $this->fixtureManifest->getGroupByClassName($loopingRelationship); if ($group === null) { continue; } // Loop through each Record and remove this relationship. foreach ($group->getRecords() as $record) { $record->removeRelationshipValueForClass($toClass); } } } // Re-fetch the relationships now that any intersections have been removed. $childToClass = $relationships[$toClass]; // Check to see if we still have any relationships that need to be traversed. if (count($childToClass) === 0) { continue; } $parentage[] = $toClass; // This needs to be recursive, rather than a stack (while loop). It's important that we traverse one entire // tree before starting a new one. $this->removeLoopingRelationships($parentage, $childToClass); } } /** * @param string $message */ protected function addWarning(string $message): void { if (in_array($message, $this->warnings)) { return; } $this->warnings[] = $message; } } |