diff --git a/lib/QubitFlatfileImport.class.php b/lib/QubitFlatfileImport.class.php index a8d6f6bfb6..03e8b911ca 100644 --- a/lib/QubitFlatfileImport.class.php +++ b/lib/QubitFlatfileImport.class.php @@ -33,6 +33,7 @@ class QubitFlatfileImport public $searchIndexingDisabled = true; // disable per-object search indexing by default public $disableNestedSetUpdating = false; // update nested set on object creation public $matchAndUpdate = false; // match existing records & update them + public $clearAndUpdate = false; // clear matching records & update them public $deleteAndReplace = false; // delete matching records & replace them public $skipMatched = false; // skip creating new record if matching one is found public $skipUnmatched = false; // skip creating new record if matching one is not found @@ -67,6 +68,10 @@ class QubitFlatfileImport // Replaceable logic to filter content before entering Qubit public $contentFilterLogic; + public $contentLogic; + + // The object being imported/updated + public $object; public function __construct($options = []) { @@ -135,6 +140,13 @@ public function setUpdateOptions($options) break; + case 'clear-and-update': + // Clear matching records before updating them in-place + $this->clearAndUpdate = true; + $this->keepDigitalObjects = $options['keep-digital-objects']; + + break; + case 'match-and-update': // Save match option. If update is ON, and match is set, only updating // existing records - do not create new objects. @@ -537,7 +549,7 @@ public function row($row = []) public function isUpdating() { - return $this->matchAndUpdate || $this->deleteAndReplace; + return $this->matchAndUpdate || $this->clearAndUpdate || $this->deleteAndReplace; } /** @@ -929,7 +941,7 @@ public function createOrFetchAndUpdateActorForIo($name, $options = []) } // Change actor history when updating a match in the same repo - if ($this->matchAndUpdate) { + if ($this->matchAndUpdate || $this->clearAndUpdate) { $actor->history = $options['history']; $actor->save(); @@ -1914,6 +1926,10 @@ private function handleInformationObjectRow() $this->handleDeleteAndReplace(); } + if ($this->clearAndUpdate) { + $this->handleClearAndUpdate(); + } + // Execute ad-hoc row pre-update logic (remove related data, etc.) $this->executeClosurePropertyIfSet('updatePreparationLogic'); $skipRowProcessing = false; @@ -1941,6 +1957,9 @@ private function getActionDescription() if ($this->matchAndUpdate) { return 'updating in place'; } + if ($this->clearAndUpdate) { + return 'clearing and updating in place'; + } return 'skipping'; } @@ -1962,6 +1981,131 @@ private function handleDeleteAndReplace() $this->object->slug = $oldSlug; // Retain previous record's slug } + /** + * Clear the content of the given object to prepare it to be re-defined in place. This enables + * all fields of information to be re-written without having to delete the object. + * + * Clears: + * - Direct properties of the object + * - Properties on i18n objects for the selected culture + * + * Deletes: + * - Related QubitObjectTermRelation objects + * - Related QubitProperty objects + * - QubitRelation objects where this is the "Object" part of the relationship + * - QubitRelation objects where this is the "Subject" part of the relationship (except for + * related description relationships which can't be imported via CSV) + */ + private function handleClearAndUpdate() + { + $directProperties = []; + + if ($this->object instanceof QubitInformationObject) { + $directProperties = [ + 'descriptionIdentifier', + 'descriptionDetailId', + 'descriptionStatusId', + 'levelOfDescriptionId', + 'repositoryId', + ]; + } else { + throw new sfException( + 'Cannot handle clear-and-update for objects that are not QubitInformationObject! Got: '.get_class($this->object) + ); + } + + // Clear all properties that exist on the object itself + // e.g., Description identifier, level of description + foreach ($directProperties as $directProperty) { + $this->object->{$directProperty} = null; + } + + // Clear i18n object for the given culture + // e.g., Title, Scope and content + $culture = $this->columnValue('culture'); + $i18ns = $this->object->informationObjectI18ns->indexBy('culture'); + + if (isset($i18ns[$culture])) { + $i18n = $i18ns[$culture]; + + foreach ($this->standardColumns as $column) { + if (in_array($column, ['createdAt', 'updatedAt', 'culture'])) { + continue; + } + $i18n->{$column} = null; + } + } + + // Remove all object-term relations + // e.g., Place access points, name access points + $criteria = new Criteria(); + $criteria->add(QubitObjectTermRelation::OBJECT_ID, $this->object->id); + $objectTermRelations = QubitObjectTermRelation::get($criteria); + + foreach ($objectTermRelations as $objectTermRelation) { + $objectTermRelation->delete(); + } + + // Remove all notes + // e.g., Archivist note, Credits note + $criteria = new Criteria(); + $criteria->add(QubitNote::OBJECT_ID, $this->object->id); + $notes = QubitNote::get($criteria); + + foreach ($notes as $note) { + $note->delete(); + } + + // Remove all events + $criteria = new Criteria(); + $criteria->add(QubitEvent::OBJECT_ID, $this->object->id); + $events = QubitEvent::get($criteria); + + foreach ($events as $event) { + $event->delete(); + } + + // Remove all special properties stored as QubitProperty objects + // e.g., Script of description, Alternative identifiers + $properties = $this->object->getProperties(); + + foreach ($properties as $property) { + $property->delete(); + } + + // Remove relationships where the relationship terminates at this object + // e.g., Physical object -> has -> Information object + $criteria = new Criteria(); + $criteria = $this->object->addrelationsRelatedByobjectIdCriteria($criteria); + $objectRelations = QubitRelation::get($criteria); + + foreach ($objectRelations as $relation) { + $relation->delete(); + } + + // Remove relationships where the relationship originates from this object + // e.g., Information object -> has -> Accession object + $criteria = new Criteria(); + $criteria = $this->object->addrelationsRelatedBysubjectIdCriteria($criteria); + $subjectRelations = QubitRelation::get($criteria); + + foreach ($subjectRelations as $relation) { + // There is no way to import this type of relationship, so skip removing it + if (QubitTerm::RELATED_MATERIAL_DESCRIPTIONS_ID == $relation->typeId) { + continue; + } + + $relation->delete(); + } + + // Remove digital object unless --keep-digital-objects is set + if (!$this->keepDigitalObjects) { + if (null !== $do = $this->object->getDigitalObject()) { + $do->delete(); + } + } + } + /** * Creates a new information object if --skip-unmatched isn't set in options. */ @@ -2130,8 +2274,8 @@ private function handleRepositoryAndActorRow() // Execute ad-hoc row pre-update logic (remove related data, etc.) $this->executeClosurePropertyIfSet('updatePreparationLogic'); - // Match and update: update current object - if ($this->matchAndUpdate) { + // Match and update & clear and update: update current object + if ($this->matchAndUpdate || $this->clearAndUpdate) { return false; } diff --git a/lib/task/import/csvImportBaseTask.class.php b/lib/task/import/csvImportBaseTask.class.php index 04a001b47e..62b4c3bc39 100644 --- a/lib/task/import/csvImportBaseTask.class.php +++ b/lib/task/import/csvImportBaseTask.class.php @@ -24,6 +24,9 @@ */ abstract class csvImportBaseTask extends arBaseTask { + // Some classes may not implement this method, enable this feature only if the sub-class explicitly allows for it + protected bool $enableClearAndUpdate = false; + /** * If updating, delete existing digital object if updating, a path or UI has * been specified, and not keeping digial objects. @@ -490,11 +493,22 @@ protected function validateOptions($options) throw new sfException('The --limit option requires the --update option to be present.'); } - if ($options['keep-digital-objects'] && 'match-and-update' != trim($options['update'])) { - throw new sfException('The --keep-digital-objects option can only be used when --update=\'match-and-update\' option is present.'); + if ($options['keep-digital-objects']) { + $updateMode = trim($options['update']); + + if (!$this->enableClearAndUpdate && 'match-and-update' != $updateMode) { + throw new sfException('The --keep-digital-objects option can only be used when --update=\'match-and-update\' option is present.'); + } + if ($this->enableClearAndUpdate && !array_search($updateMode, ['match-and-update', 'clear-and-update'])) { + throw new sfException('The --keep-digital-objects option can only be used when the --update=\'match-and-update\' or --update=\'clear-and-update\' option is present.'); + } } $this->validateUpdateOptions($options); + + if ($this->enableClearAndUpdate && 'clear-and-update' === trim($options['update'])) { + echo "WARNING: The clear-and-update import mode is experimental.\n"; + } } /** @@ -510,6 +524,10 @@ protected function validateUpdateOptions($options) $validParams = ['match-and-update', 'delete-and-replace']; + if ($this->enableClearAndUpdate) { + $validParams[] = 'clear-and-update'; + } + if (!in_array(trim($options['update']), $validParams)) { $msg = sprintf('Parameter "%s" is not valid for --update option. ', $options['update']); $msg .= sprintf('Valid options are: %s', implode(', ', $validParams)); diff --git a/lib/task/import/csvImportTask.class.php b/lib/task/import/csvImportTask.class.php index d5f8b68a32..a2b4275a14 100644 --- a/lib/task/import/csvImportTask.class.php +++ b/lib/task/import/csvImportTask.class.php @@ -24,12 +24,15 @@ */ class csvImportTask extends csvImportBaseTask { + // Enable clearing and updating + protected bool $enableClearAndUpdate = true; + protected $namespace = 'csv'; protected $name = 'import'; protected $briefDescription = 'Import csv information object data'; protected $detailedDescription = <<<'EOF' -Import CSV data +Import new or update existing information objects via CSV EOF; /** @@ -913,7 +916,7 @@ protected function configure() 'update', null, sfCommandOption::PARAMETER_REQUIRED, - 'Attempt to update if description has already been imported. Valid option values are "match-and-update" & "delete-and-replace".' + 'Attempt to update if description has already been imported. Valid option values are "match-and-update", "clear-and-update", & "delete-and-replace".' ), new sfCommandOption( 'skip-matched', @@ -950,7 +953,7 @@ protected function configure() 'keep-digital-objects', null, sfCommandOption::PARAMETER_NONE, - 'Skip the deletion of existing digital objects and their derivatives when using --update with "match-and-update".' + 'Skip the deletion of existing digital objects and their derivatives when using --update with "match-and-update" or "clear-and-update".' ), new sfCommandOption( 'roundtrip', diff --git a/test/phpunit/QubitFlatfileImportTest.php b/test/phpunit/QubitFlatfileImportTest.php new file mode 100644 index 0000000000..bd0f4cf1cf --- /dev/null +++ b/test/phpunit/QubitFlatfileImportTest.php @@ -0,0 +1,141 @@ +createMock(QubitDigitalObject::class); + $mockDigitalObject->expects($this->once()) + ->method('delete'); + + // Create mock information object + $mockInformationObject = $this->getMockBuilder(QubitInformationObject::class) + ->onlyMethods([ + 'getDigitalObject', + 'getProperties', + ]) + ->getMock(); + + $mockInformationObject->method('getDigitalObject') + ->willReturn($mockDigitalObject); + + $mockInformationObject->method('getProperties') + ->willReturn([]); + + $mockInformationObject->informationObjectI18ns = new ArrayObject(); + + // Create importer with keepDigitalObjects = false + $import = new QubitFlatfileImport([ + 'columnNames' => ['title', 'culture'], + 'standardColumns' => ['title'], + 'keepDigitalObjects' => false, + ]); + + $import->object = $mockInformationObject; + $import->setStatus('row', ['Test title', 'en']); + + // Use reflection to call private method + $reflection = new ReflectionClass($import); + $method = $reflection->getMethod('handleClearAndUpdate'); + $method->setAccessible(true); + + $method->invoke($import); + } + + /** + * Test that handleClearAndUpdate keeps digital object when keepDigitalObjects is true. + */ + public function testHandleClearAndUpdateKeepsDigitalObjectWhenOptionSet(): void + { + // Create mock digital object - should NOT have delete called + $mockDigitalObject = $this->createMock(QubitDigitalObject::class); + $mockDigitalObject->expects($this->never()) + ->method('delete'); + + // Create mock information object + $mockInformationObject = $this->getMockBuilder(QubitInformationObject::class) + ->onlyMethods([ + 'getDigitalObject', + 'getProperties', + ]) + ->getMock(); + + $mockInformationObject->method('getDigitalObject') + ->willReturn($mockDigitalObject); + + $mockInformationObject->method('getProperties') + ->willReturn([]); + + $mockInformationObject->informationObjectI18ns = new ArrayObject(); + + // Create importer with keepDigitalObjects = true + $import = new QubitFlatfileImport([ + 'columnNames' => ['title', 'culture'], + 'standardColumns' => ['title'], + 'keepDigitalObjects' => true, + ]); + + $import->object = $mockInformationObject; + $import->setStatus('row', ['Test title', 'en']); + + // Use reflection to call private method + $reflection = new ReflectionClass($import); + $method = $reflection->getMethod('handleClearAndUpdate'); + $method->setAccessible(true); + + $method->invoke($import); + } + + /** + * Test that handleClearAndUpdate handles case when no digital object exists. + */ + public function testHandleClearAndUpdateHandlesNoDigitalObject(): void + { + // Create mock information object with no digital object + $mockInformationObject = $this->getMockBuilder(QubitInformationObject::class) + ->onlyMethods([ + 'getDigitalObject', + 'getProperties', + ]) + ->getMock(); + + $mockInformationObject->method('getDigitalObject') + ->willReturn(null); + + $mockInformationObject->method('getProperties') + ->willReturn([]); + + $mockInformationObject->informationObjectI18ns = new ArrayObject(); + + // Create importer with keepDigitalObjects = false + $import = new QubitFlatfileImport([ + 'columnNames' => ['title', 'culture'], + 'standardColumns' => ['title'], + 'keepDigitalObjects' => false, + ]); + + $import->object = $mockInformationObject; + $import->setStatus('row', ['Test title', 'en']); + + // Use reflection to call private method - should not throw + $reflection = new ReflectionClass($import); + $method = $reflection->getMethod('handleClearAndUpdate'); + $method->setAccessible(true); + + // This should complete without error + $method->invoke($import); + + $this->assertTrue(true); + } +}