8.5 KiB
Versioning of Database Content
Overview
Database content in SilverStripe can be "staged" before its publication, as well as track all changes through the lifetime of a database record.
It is most commonly applied to pages in the CMS (the SiteTree
class).
This means that draft content edited in the CMS can be different from published content
shown to your website visitors.
The versioning happens automatically on read and write. If you are using the SilverStripe ORM to perform these operations, you don't need to alter your existing calls.
Versioning in SilverStripe is handled through the [api:Versioned]
class.
It's a [api:DataExtension]
, which allow it to be applied to any [api:DataObject]
subclass.
Configuration
Adding versioned to your DataObject
subclass works the same as any other extension.
It accepts two or more arguments denoting the different "stages",
which map to different database tables. Add the following to your configuration file:
:::yml
MyRecord:
extensions:
- Versioned("Stage","Live")
Note: The extension is automatically applied to SiteTree
class.
Database Structure
Depending on how many stages you configured, two or more new tables will be created for your records. Note that the "Stage" naming has a special meaning here, it will leave the original table name unchanged, rather than adding a suffix.
MyRecord
table: Contains staged dataMyRecord_Live
table: Contains live dataMyRecord_versions
table: Contains a version history (new record created on each save)
Similarly, any subclass you create on top of a versioned base will trigger the creation of additional tables, which are automatically joined as required:
MyRecordSubclass
table: Contains only staged data for subclass columnsMyRecordSubclass_Live
table: Contains only live data for subclass columnsMyRecordSubclass_versions
table: Contains only version history for subclass columns
Usage
Reading Versions
By default, all records are retrieved from the "Draft" stage (so the MyRecord
table in our example).
You can explicitly request a certain stage through various getters on the Versioned
class.
:::php
// Fetching multiple records
$stageRecords = Versioned::get_by_stage('MyRecord', 'Stage');
$liveRecords = Versioned::get_by_stage('MyRecord', 'Live');
// Fetching a single record
$stageRecord = Versioned::get_by_stage('MyRecord', 'Stage')->byID(99);
$liveRecord = Versioned::get_by_stage('MyRecord', 'Live')->byID(99);
Historical Versions
The above commands will just retrieve the latest version of its respective stage for you,
but not older versions stored in the <class>_versions
tables.
:::php
$historicalRecord = Versioned::get_version('MyRecord', <record-id>, <version-id>);
Caution: The record is retrieved as a DataObject
, but saving back modifications
via write()
will create a new version, rather than modifying the existing one.
In order to get a list of all versions for a specific record,
we need to generate specialized [api:Versioned_Version]
objects,
which expose the same database information as a DataObject
,
but also include information about when and how a record was published.
:::php
$record = MyRecord::get()->byID(99); // stage doesn't matter here
$versions = $record->allVersions();
echo $versions->First()->Version; // instance of Versioned_Version
Writing Versions and Changing Stages
The usual call to DataObject->write()
will write to whatever stage is currently
active, as defined by the Versioned::current_stage()
global setting.
Each call will automatically create a new version in the <class>_versions
table.
To avoid this, use [writeWithoutVersion()](api:Versioned->writeWithoutVersion())
instead.
To move a saved version from one stage to another,
call [writeToStage(<stage>)](api:Versioned->writeToStage())
on the object.
The process of moving a version to a different stage is also called "publishing",
so we've created a shortcut for this: publish(<from-stage>, <to-stage>)
.
:::php
$record = Versioned::get_by_stage('MyRecord', 'Stage')->byID(99);
$record->MyField = 'changed';
// will update `MyRecord` table (assuming Versioned::current_stage() == 'Stage'),
// and write a row to `MyRecord_versions`.
$record->write();
// will copy the saved record information to the `MyRecord_Live` table
$record->publish('Stage', 'Live');
Similarly, an "unpublish" operation does the reverse, and removes a record from a specific stage.
:::php
$record = MyRecord::get()->byID(99); // stage doesn't matter here
// will remove the row from the `MyRecord_Live` table
$record->deleteFromStage('Live');
Forcing the Current Stage
The current stage is stored as global state on the object. It is usually modified by controllers, e.g. when a preview is initialized. But it can also be set and reset temporarily to force a specific operation to run on a certain stage.
:::php
$origMode = Versioned::get_reading_mode(); // save current mode
$obj = MyRecord::getComplexObjectRetrieval(); // returns 'Live' records
Versioned::set_reading_mode('Stage'); // temporarily overwrite mode
$obj = MyRecord::getComplexObjectRetrieval(); // returns 'Stage' records
Versioned::set_reading_mode($origMode); // reset current mode
Custom SQL
We generally discourage writing Versioned
queries from scratch,
due to the complexities involved through joining multiple tables
across an inherited table scheme (see [api:Versioned->augmentSQL()]
).
If possible, try to stick to smaller modifications of the generated DataList
objects.
Example: Get the first 10 live records, filtered by creation date:
:::php
$records = Versioned::get_by_stage('MyRecord', 'Live')->limit(10)->sort('Created', 'ASC');
Permissions
The Versioned
extension doesn't provide any permissions on its own,
but you can have a look at the SiteTree
class for implementation samples,
specifically canPublish()
and canDeleteFromStage()
.
Page Specific Operations
Since the Versioned
extension is primarily used for page objects,
the underlying SiteTree
class has some additional helpers.
See the "sitetree" reference for details.
Templates Variables
In templates, you don't need to worry about this distinction.
The $Content
variable contain the published content by default,
and only preview draft content if explicitly requested (e.g. by the "preview" feature in the CMS).
If you want to force a specific stage, we recommend the Controller->init()
method for this purpose.
Controllers
The current stage for each request is determined by VersionedRequestFilter
before
any controllers initialize, through Versioned::choose_site_stage()
.
It checks for a Stage
GET parameter, so you can force
a draft stage by appending ?stage=Stage
to your request. The setting is "sticky"
in the PHP session, so any subsequent requests will also be in draft stage.
Important: The choose_site_stage()
call only deals with setting the default stage,
and doesn't check if the user is authenticated to view it. As with any other controller logic,
please use DataObject->canView()
to determine permissions, and avoid exposing unpublished
content to your users.
:::php
class MyController extends Controller {
private static $allowed_actions = array('showpage');
public function showpage($request) {
$page = Page::get()->byID($request->param('ID'));
if(!$page->canView()) return $this->httpError(401);
// continue with authenticated logic...
}
}
The ContentController
class responsible for page display already has this built in,
so your own canView()
checks are only necessary in controllers extending directly
from the Controller
class.
Recipes
It can be useful to add the variable $SilverStripeNavigator
somewhere into the template, since it allows you to put a mini "admin" bar on the page which isn't visible to non editors. It shows the current stage and provides a convenient CMS link and version changing link.
Keep in mind that $SilverStripeNavigator
is only available on ContentController, so useful when the Versioned extension is applied to SiteTree (default), and not when it's applied to DataObject.
Trapping the publication event
Sometimes, you'll want to do something whenever a particular kind of page is published. This example sends an email whenever a blog entry has been published.
:::php
class Page extends SiteTree {
// ...
public function onAfterPublish() {
mail("sam@silverstripe.com", "Blog published", "The blog has been published");
}
}