Skip to content

Commit

Permalink
Initial release
Browse files Browse the repository at this point in the history
  • Loading branch information
Zauberfisch committed Aug 24, 2017
1 parent 9b9595b commit 3c781a2
Show file tree
Hide file tree
Showing 14 changed files with 512 additions and 3 deletions.
17 changes: 17 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions .htaccess
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<FilesMatch "\.(php|php3|php4|php5|phtml|inc)$">
Deny from all
</FilesMatch>
36 changes: 34 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
Empty file added _config/.gitignore
Empty file.
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,6 @@
"issues": "https://github.com/Zauberfisch/silverstripe-persistent-dataobject/issues"
},
"require": {
"silverstripe/framework": "^3"
"silverstripe/framework": "^3.6"
}
}
39 changes: 39 additions & 0 deletions src/Admin/ModelAdmin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

namespace zauberfisch\PersistentDataObject\Admin;

use zauberfisch\PersistentDataObject\Form\GridField\Config_RecordEditor;
use zauberfisch\PersistentDataObject\Form\GridField\DetailForm;

/**
* @author zauberfisch
*/
class ModelAdmin extends \ModelAdmin {
public function getEditForm($id = null, $fields = null) {
/** @var \Form $return */
$return = parent::getEditForm($id, $fields);
/** @var \GridField $grid */
$grid = $return->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;
}
}

25 changes: 25 additions & 0 deletions src/Form/GridField/Action/EditButton.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

namespace zauberfisch\PersistentDataObject\Form\GridField\Action;

use zauberfisch\PersistentDataObject\Model\VersionedDataObjectExtension;

class EditButton extends \GridFieldEditButton {
public function getColumnContent($gridField, $record, $columnName) {
if ($record->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);
}
}
19 changes: 19 additions & 0 deletions src/Form/GridField/Config_RecordEditor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

namespace zauberfisch\PersistentDataObject\Form\GridField;

use zauberfisch\PersistentDataObject\Form\GridField\Action\EditButton;
use zauberfisch\PersistentDataObject\Form\GridField\Filter\HideDeletedFilter;
use zauberfisch\PersistentDataObject\Form\GridField\Filter\LatestVersionFilter;

class Config_RecordEditor extends \GridFieldConfig_RecordEditor {
public function __construct($itemsPerPage = null) {
parent::__construct($itemsPerPage);
$this->removeComponentsByType(\GridFieldDetailForm::class);
$this->addComponent(new DetailForm());
$this->removeComponentsByType(\GridFieldEditButton::class);
$this->addComponent(new EditButton());
$this->addComponent(new LatestVersionFilter());
$this->addComponent(new HideDeletedFilter());
}
}
59 changes: 59 additions & 0 deletions src/Form/GridField/DetailForm.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

namespace zauberfisch\PersistentDataObject\Form\GridField;

use zauberfisch\PersistentDataObject\Model\VersionedDataObjectExtension;

class DetailForm extends \GridFieldDetailForm {
public function getURLHandlers($gridField) {
return array_merge([
'version-group/$VersionGroupID/item/$ID' => '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());
}

}
28 changes: 28 additions & 0 deletions src/Form/GridField/DetailForm_ItemRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

namespace zauberfisch\PersistentDataObject\Form\GridField;

use zauberfisch\PersistentDataObject\Model\VersionedDataObjectExtension;

class DetailForm_ItemRequest extends \GridFieldDetailForm_ItemRequest {
private static $allowed_actions = [
'edit',
'view',
'ItemEditForm',
];

public function Link($action = null) {
$r = $this->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);
}
}
19 changes: 19 additions & 0 deletions src/Form/GridField/Filter/HideDeletedFilter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

namespace zauberfisch\PersistentDataObject\Form\GridField\Filter;

class HideDeletedFilter implements \GridField_DataManipulator {
/**
* @param \GridField $gridField
* @param \SS_List|\DataList|\ArrayList $dataList
* @return mixed
*/
public function getManipulatedData(\GridField $gridField, \SS_List $dataList) {
$latest = $dataList->filter('VersionGroupLatest', true);
$groupIDs = array_merge(
[0],
$latest->exclude('Deleted', 1)->column('VersionGroupID')
);
return $dataList->filter('VersionGroupID', $groupIDs);
}
}
14 changes: 14 additions & 0 deletions src/Form/GridField/Filter/LatestVersionFilter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

namespace zauberfisch\PersistentDataObject\Form\GridField\Filter;

class LatestVersionFilter implements \GridField_DataManipulator {
/**
* @param \GridField $gridField
* @param \SS_List|\DataList|\ArrayList $dataList
* @return mixed
*/
public function getManipulatedData(\GridField $gridField, \SS_List $dataList) {
return $dataList->filter('VersionGroupLatest', true);
}
}
119 changes: 119 additions & 0 deletions src/Model/DataObject.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<?php

//namespace zauberfisch\PersistentDataObject\Model;

/**
* DataObject classes can not be namespaced in SilverStripe 3.x
* @author zauberfisch
* @property boolean $Deleted
* @property int $VersionGroupID
* @property boolean $VersionGroupLatest
*/
class PersistentDataObject_Model_DataObject extends \DataObject {
private static $db = [
'Deleted' => '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;
}
}
Loading

0 comments on commit 3c781a2

Please sign in to comment.