From 3c781a275d9ebf135fcc788ba88b53f60886fb05 Mon Sep 17 00:00:00 2001 From: zauberfisch Date: Thu, 24 Aug 2017 05:35:43 +0000 Subject: [PATCH] Initial release --- .editorconfig | 17 +++ .htaccess | 3 + README.md | 36 ++++- _config/.gitignore | 0 composer.json | 2 +- src/Admin/ModelAdmin.php | 39 +++++ src/Form/GridField/Action/EditButton.php | 25 ++++ src/Form/GridField/Config_RecordEditor.php | 19 +++ src/Form/GridField/DetailForm.php | 59 ++++++++ src/Form/GridField/DetailForm_ItemRequest.php | 28 ++++ .../GridField/Filter/HideDeletedFilter.php | 19 +++ .../GridField/Filter/LatestVersionFilter.php | 14 ++ src/Model/DataObject.php | 119 +++++++++++++++ src/Model/VersionedDataObjectExtension.php | 135 ++++++++++++++++++ 14 files changed, 512 insertions(+), 3 deletions(-) create mode 100644 .editorconfig create mode 100644 .htaccess create mode 100644 _config/.gitignore create mode 100644 src/Admin/ModelAdmin.php create mode 100644 src/Form/GridField/Action/EditButton.php create mode 100644 src/Form/GridField/Config_RecordEditor.php create mode 100644 src/Form/GridField/DetailForm.php create mode 100644 src/Form/GridField/DetailForm_ItemRequest.php create mode 100644 src/Form/GridField/Filter/HideDeletedFilter.php create mode 100644 src/Form/GridField/Filter/LatestVersionFilter.php create mode 100644 src/Model/DataObject.php create mode 100644 src/Model/VersionedDataObjectExtension.php diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..9857e47 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +# For more information about the properties used in this file, +# please see the EditorConfig documentation: +# http://editorconfig.org + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = tab +insert_final_newline = true +trim_trailing_whitespace = true + +[{*.yml,package.json}] +indent_size = 2 + +# The indent size used in the package.json file cannot be changed: +# https://github.com/npm/npm/pull/3180#issuecomment-16336516 diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..fad44cc --- /dev/null +++ b/.htaccess @@ -0,0 +1,3 @@ + + Deny from all + \ No newline at end of file diff --git a/README.md b/README.md index 0f61036..a796041 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,34 @@ -# silverstripe-persistent-dataobject -Persistent, immutable and optionally versioned DataObjects for SilverStripe 4 +# Persistent DataObjects - Experimental / Work in Progress + +Persistent and optionally immutable & versioned DataObjects for SilverStripe + +The two major features of this module are: + +1. **A DataObject subclass that can not be deleted** + Calling `->delete()` will mark an object as deleted, but not actually delete it + *(Where necessary, objects can still be deleted by calling `->purge()`)* +1. **A DataObjectExtension that adds versioning** + In contrast to the "silverstripe-versioned" module, versioning is achived by + making DataObjects immutable and overloading `->write()` to create a duplicate + of the current record instead of saving the existing. + This means the `ID` becomes the unique version number, while an additional + `VersionGroupID` and `VersionGroupLatest` keep track of the relation of records. + The benefit of this approach is being able to easily reference a version of an + entry rather than always the latest version. Thus making it possible to have + persistent storage of information that is easily integrable in other parts of + SilverStripe (eg an invoice can safely reference a product price and does not + need to create a snapshot). + +## TODOs / Planed features + +- [ ] Tests +- [ ] Revisit decision to put VersionGroup_ID on DataObject rather than subclasses +- [ ] Extend GridField integration + - [ ] Hide/Show deleted records button + - [ ] History view to access older version from within a DataObject +- [ ] Implement non GridField form fields (eg Relation Dropdown that let's the user pick a entry and a version) +- [ ] Implement Database Field/Relation that references VersionGroupID instead of ID? +- [ ] Documentation + - [ ] Explain the usecase in more detail + - [ ] Examples +- [ ] SilverStripe 4 Support diff --git a/_config/.gitignore b/_config/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/composer.json b/composer.json index d70f895..6147f5c 100644 --- a/composer.json +++ b/composer.json @@ -15,6 +15,6 @@ "issues": "https://github.com/Zauberfisch/silverstripe-persistent-dataobject/issues" }, "require": { - "silverstripe/framework": "^3" + "silverstripe/framework": "^3.6" } } diff --git a/src/Admin/ModelAdmin.php b/src/Admin/ModelAdmin.php new file mode 100644 index 0000000..d3b6b8d --- /dev/null +++ b/src/Admin/ModelAdmin.php @@ -0,0 +1,39 @@ +Fields()->fieldByName($this->sanitiseClassName($this->modelClass)); + $config = new Config_RecordEditor(); + $config->removeComponentsByType(\GridFieldFilterHeader::class); + // Validation + if (singleton($this->modelClass)->hasMethod('getCMSValidator')) { + $detailValidator = singleton($this->modelClass)->getCMSValidator(); + /** @var DetailForm $detailForm */ + $detailForm = $config->getComponentByType(DetailForm::class); + $detailForm->setValidator($detailValidator); + } + // Import / Export + $config->addComponent((new \GridFieldExportButton('buttons-before-left'))->setExportColumns($this->getExportFields())); + //if ($this->showImportForm) { + // $config->addComponent( + // GridFieldImportButton::create('buttons-before-left') + // ->setImportForm($this->ImportForm()) + // ->setModalTitle(_t('ModelAdmin.IMPORT', 'Import from CSV')) + // ); + //} + $grid->setConfig($config); + return $return; + } +} + diff --git a/src/Form/GridField/Action/EditButton.php b/src/Form/GridField/Action/EditButton.php new file mode 100644 index 0000000..0bfc771 --- /dev/null +++ b/src/Form/GridField/Action/EditButton.php @@ -0,0 +1,25 @@ +hasExtension(VersionedDataObjectExtension::class)) { + /** @var \PersistentDataObject_Model_DataObject|VersionedDataObjectExtension $record */ + $data = new \ArrayData([ + 'Link' => \Controller::join_links( + $gridField->Link('version-group'), + $record->VersionGroupID, + 'item', + $record->isLatestVersion(true) ? 'latest' : $record->ID, + 'edit' + ), + ]); + $template = \SSViewer::get_templates_by_class(\GridFieldEditButton::class, '', \GridFieldEditButton::class); + return $data->renderWith($template); + } + return parent::getColumnContent($gridField, $record, $columnName); + } +} diff --git a/src/Form/GridField/Config_RecordEditor.php b/src/Form/GridField/Config_RecordEditor.php new file mode 100644 index 0000000..0f09848 --- /dev/null +++ b/src/Form/GridField/Config_RecordEditor.php @@ -0,0 +1,19 @@ +removeComponentsByType(\GridFieldDetailForm::class); + $this->addComponent(new DetailForm()); + $this->removeComponentsByType(\GridFieldEditButton::class); + $this->addComponent(new EditButton()); + $this->addComponent(new LatestVersionFilter()); + $this->addComponent(new HideDeletedFilter()); + } +} diff --git a/src/Form/GridField/DetailForm.php b/src/Form/GridField/DetailForm.php new file mode 100644 index 0000000..ab5e875 --- /dev/null +++ b/src/Form/GridField/DetailForm.php @@ -0,0 +1,59 @@ + 'handleVersionedItem', + ], parent::getURLHandlers($gridField)); + } + + /** + * @param \GridField $gridField + * @param \SS_HTTPRequest $request + * @return \GridFieldDetailForm_ItemRequest|\RequestHandler + */ + public function handleVersionedItem($gridField, $request) { + // Our getController could either give us a true Controller, if this is the top-level GridField. + // It could also give us a RequestHandler in the form of GridFieldDetailForm_ItemRequest if this is a + // nested GridField. + $requestHandler = $gridField->getForm()->getController(); + + $versionedID = $request->param('VersionGroupID'); + $id = $request->param('ID'); + + if ($versionedID && is_numeric($versionedID)) { + /** @var \DataList $list */ + $list = $gridField->getList(); + $list = $list->filter('VersionGroupID', $versionedID); + if ($id && is_numeric($id)) { + /** @var \DataObject|\PersistentDataObject_Model_DataObject|VersionedDataObjectExtension $record */ + $record = $list->byID($id); + } else { + $record = $list->filter('VersionGroupLatest', true)->first(); + } + } else { + $record = \Object::create($gridField->getModelClass()); + } + $class = $this->getItemRequestClass(); + /** @var DetailForm_ItemRequest $handler */ + $handler = \Object::create($class, $gridField, $this, $record, $requestHandler, $this->name); + $handler->setTemplate($this->template); + + // if no validator has been set on the GridField and the record has a CMS validator, use that. + if (!$this->getValidator() + && ( + method_exists($record, 'getCMSValidator') + || $record instanceof \Object && $record->hasMethod('getCMSValidator') + ) + ) { + /** @noinspection PhpUndefinedMethodInspection */ + $this->setValidator($record->getCMSValidator()); + } + return $handler->handleRequest($request, \DataModel::inst()); + } + +} diff --git a/src/Form/GridField/DetailForm_ItemRequest.php b/src/Form/GridField/DetailForm_ItemRequest.php new file mode 100644 index 0000000..a06faa0 --- /dev/null +++ b/src/Form/GridField/DetailForm_ItemRequest.php @@ -0,0 +1,28 @@ +record; + if ($r && $r->hasExtension(VersionedDataObjectExtension::class)) { + /** @var \DataObject|VersionedDataObjectExtension $r */ + return \Controller::join_links( + $this->gridField->Link('version-group'), + $r->VersionGroupID ?: 'new', + 'item', + $r->isLatestVersion(true) ? 'latest' : $r->ID, + $action + ); + } + return parent::Link($action); + } +} diff --git a/src/Form/GridField/Filter/HideDeletedFilter.php b/src/Form/GridField/Filter/HideDeletedFilter.php new file mode 100644 index 0000000..6b83f30 --- /dev/null +++ b/src/Form/GridField/Filter/HideDeletedFilter.php @@ -0,0 +1,19 @@ +filter('VersionGroupLatest', true); + $groupIDs = array_merge( + [0], + $latest->exclude('Deleted', 1)->column('VersionGroupID') + ); + return $dataList->filter('VersionGroupID', $groupIDs); + } +} diff --git a/src/Form/GridField/Filter/LatestVersionFilter.php b/src/Form/GridField/Filter/LatestVersionFilter.php new file mode 100644 index 0000000..2ec5827 --- /dev/null +++ b/src/Form/GridField/Filter/LatestVersionFilter.php @@ -0,0 +1,14 @@ +filter('VersionGroupLatest', true); + } +} diff --git a/src/Model/DataObject.php b/src/Model/DataObject.php new file mode 100644 index 0000000..c26d0ae --- /dev/null +++ b/src/Model/DataObject.php @@ -0,0 +1,119 @@ + 'Boolean', + 'VersionGroupID' => 'Int', + 'VersionGroupLatest' => 'Boolean', + ]; + private static $defaults = [ + 'VersionGroupLatest' => true, + ]; + + public function requireTable() { + parent::requireTable(); + } + + + public function getCMSFields() { + $return = new FieldList([ + new TabSet('Root'), + ]); + $return->addFieldsToTab('Root', [ + new Tab('Main', _t(self::class . '.MainTab', 'Main')), + ]); + $s = new FormScaffolder($this); + $return->addFieldsToTab('Root.Main', $s->getFieldList()->toArray()); + $return->removeByName('Deleted'); + $return->removeByName('VersionGroupID'); + $return->removeByName('VersionGroupLatest'); + return $return; + } + + public function delete() { + $this->extend('onBeforeMarkDeleted'); + $this->Deleted = true; + $this->write(); + $this->flushCache(); + $this->extend('onAfterMarkDeleted'); + } + + public function purge() { + $this->extend('onBeforePurge'); + parent::delete(); + $this->extend('onAfterPurge'); + } + + public function canDelete($member = null) { + return $this->isDeleted() ? false : parent::canDelete($member); + } + + public function canPurge($member = null) { + // only allow purging a record if it has been marked as deleted, making deletion a 2 step process + return $this->isDeleted() ? parent::canDelete($member) : false; + } + + public function canEdit($member = null) { + return $this->isDeleted() ? false : parent::canEdit($member); + } + + public function isDeleted() { + $result = (bool)$this->Deleted; + $results = $this->extend('isDeleted'); + if ($results && is_array($results)) { + // Remove NULLs + $results = array_filter($results, function ($v) { + return !is_null($v); + }); + // If there are any non-NULL responses, then return the lowest one of them. + // If any explicitly deny the permission, then we don't get access + if ($results) { + $result = (bool)min($results); + } + } + return $result; + } + + protected function writeBaseRecord($baseTable, $now) { + $this->extend('onBeforeWriteBaseRecord', $baseTable, $now); + parent::writeBaseRecord($baseTable, $now); + $this->onAfterWriteBaseRecord($baseTable, $now); + $this->extend('onAfterWriteBaseRecord', $baseTable, $now); + } + + public function onAfterWriteBaseRecord($baseTable, $now) { + if (!$this->owner->getRawFieldData('VersionGroupID')) { + $id = $this->owner->getRawFieldData('ID'); + $this->owner->setRawFieldData('VersionGroupID', $id); + (new \SQLUpdate('"' . $baseTable . '"')) + ->assign('"VersionGroupID"', $id) + //->assign('"VersionGroupLatest"', 1) + ->addWhere(['"ID"' => $id]) + ->execute(); + } + } + + protected function onBeforeWrite() { + parent::onBeforeWrite(); + $this->owner->setRawFieldData('VersionGroupLatest', 1); + } + + + public function setRawFieldData($name, $value) { + $this->record[$name] = $value; + return $this; + } + + public function getRawFieldData($name) { + return isset($this->record[$name]) ? $this->record[$name] : null; + } +} diff --git a/src/Model/VersionedDataObjectExtension.php b/src/Model/VersionedDataObjectExtension.php new file mode 100644 index 0000000..41e8b9a --- /dev/null +++ b/src/Model/VersionedDataObjectExtension.php @@ -0,0 +1,135 @@ + 'Int', + //'VersionGroupID' => 'Int', + //'VersionGroupLatest' => 'Boolean', + ]; + + public function onBeforeWriteBaseRecord($baseTable, $now) { + $versionID = $this->owner->getRawFieldData('VersionGroupID'); + if ($versionID) { + (new \SQLUpdate('"' . $baseTable . '"')) + ->assign('"VersionGroupLatest"', 0) + ->addWhere(['"VersionGroupID"' => $versionID, 'VersionGroupLatest' => 1]) + ->execute(); + } + } + + //public function onAfterWriteBaseRecord($baseTable, $now) { + // if (!$this->owner->getRawFieldData('VersionGroupID')) { + // $id = $this->owner->getRawFieldData('ID'); + // $this->owner->setRawFieldData('VersionGroupID', $id); + // (new \SQLUpdate('"' . $baseTable . '"')) + // ->assign('"VersionGroupID"', $id) + // //->assign('"VersionGroupLatest"', 1) + // ->addWhere(['"ID"' => $id]) + // ->execute(); + // } + //} + + public function onBeforeWrite() { + $isInDB = $this->owner->isInDB(); + // TODO how do we handle saving of old versions? + // TODO The PageBuilder module may need to revert back to an older version of a record and continue from there + //if ($isInDB && !$this->owner->isLatestVersion(true)) { + // throw new \ValidationException("Old versions can not be written"); + //} + $this->owner->setRawFieldData('VersionGroupPreviousID', $this->owner->ID); + $this->owner->setRawFieldData('ID', 0); + parent::onBeforeWrite(); + } + + public function onBeforePurge() { + if ($this->owner->isLatestVersion(true)) { + // TODO handle purging of latest version + } + } + + public function purgeAllVersions() { + $id = $this->owner->getRawFieldData('ID'); + $this->owner->getVersions()->each(function ($obj) { + /** @var \PersistentDataObject_Model_DataObject $obj */ + $obj->purge(); + }); + $this->owner->flushCache(); + $this->owner->Deleted = true; + $this->owner->OldID = $id; + $this->owner->ID = 0; + } + + ///** + // * @param \Member $member + // * @return bool|null + // */ + //public function canEdit($member = null) { + // if ($this->owner->isLatestVersion()) { + // return parent::canEdit($member); + // } + // return false; + //} + // + ///** + // * @param \Member $member + // * @return bool|null + // */ + //public function canDelete($member = null) { + // if ($this->owner->isLatestVersion()) { + // return parent::canDelete($member); + // } + // return false; + //} + + /** + * @param \Member $member + * @return bool|null + */ + public function canPurge($member = null) { + // TODO + return false; + } + + /** + * @param bool $skipCache + * @return bool + */ + public function isLatestVersion($skipCache = false) { + if ($this->owner->isInDB()) { + return !$skipCache ? $this->owner->VersionGroupLatest : $this->owner->get()->filter(['ID' => $this->owner->ID, 'VersionGroupLatest' => true])->exists(); + } + return true; + } + + /** + * @param bool $skipCache + * @return $this|\PersistentDataObject_Model_DataObject + */ + public function getLatestVersion($skipCache = false) { + if (!$this->owner->isInDB() || $this->owner->isLatestVersion($skipCache)) { + return $this; + } + return $this->owner->getVersions()->filter(['VersionGroupLatest' => true])->first(); + } + + /** + * @return \DataList|\DataObject[]|static[] + */ + public function getVersions() { + return $this->owner->get()->filter(['VersionGroupID' => $this->owner->VersionGroupID])->sort('ID', 'DESC'); + } + + /** + * @return bool + */ + public function isDeleted() { + //return $this->isLatestVersion(true) ? $this->Versioned_Deleted : static::get()->filter(['VersionGroupID' => $this->VersionGroupID, 'VersionGroupLatest' => true, 'Versioned_Deleted' => true])->exists(); + return $this->owner->isLatestVersion(true) ? $this->owner->Deleted : $this->owner->getVersions()->filter(['VersionGroupLatest' => true, 'Deleted' => true])->exists(); + } +}