Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP

Loading…

FIXED: Crashed caused by viewing versioned page #716

Merged
merged 5 commits into from

4 participants

@tractorcow
Collaborator

When viewing the archived version of a page the augmentSQL function of the Versioned extension would generate invalid SQL. E.g.

SELECT 
...
FROM SiteTree_versions 
AND Page_versions.Version = SiteTree_versions.Version 
INNER JOIN _ArchiveSiteTree ON _ArchiveSiteTree.ID = SiteTree_versions.RecordID AND _ArchiveSiteTree.Version = SiteTree_versions.Version
...

This is due to an odd $query->addFrom which attempts to re-add an existing from table name, but with an additional join condition; However, it doesn't actually do that, but it adds a SQL fragment "AND .... " in the place of the table name. It's possible that I may have misunderstood the intent of this code. Possibly it assumes the addFrom will perform a recursive table/join/condition merge. Actually, addFrom instead completely replaces the existing from condition.

The actual addFrom is not necessary as the target table would already be listed in the from items.

I have replaced the addFrom with an addWhere condition to add in the new join condition. This however doesn't add the condition in the actual join itself, but in the 'where' clause. This is still valid SQL and achieves the same purpose. I tried a version of this code using addFilterToJoin, but not every "from" in a query (such as _ArchiveSiteTree) would be guaranteed to be in an array format (that addFilterToJoin requires). Could add additional code to check, ensure queries in required format, joined classes are subclasses of $baseTable, but, code is a burden, so simpler is better.

This has been tested against the VersionedTest and passes all 12 tests.

Might need to test this against other modules which augmentSQL (subsites, translatable, etc).

Additionally, there was as small but relevant bugfix where versioned tables were being joined against the wrong fields (ID,Version instead of RecordID, Version). This came about because in the $query->replaceText the tables were renamed first from table to table_versions, and then the string replacer replaced table.id with table.recordID (which by this time, no longer existed).

So much testing investigation and documentation for a small change. :)

@sminnee sminnee closed this
@sminnee sminnee reopened this
@travisbot

This pull request passes (merged c55b018 into 023721a).

@sminnee
Owner

Thanks very much for this - I don't suppose you could supply a unit test that was previously failing and now works? Something in VersionedTest.php, perhaps?

@tractorcow
Collaborator

I'll look into writing a test next week, if I'm unable to get it out this afternoon. Will let you know.

Thanks for looking at this for me.

@sminnee
Owner

Sweet as. Just commit to tractorcow:3.0-versioned-fixes and then we'll merge

tractorcow added some commits
@tractorcow tractorcow FIXED: Issue where temporary table would cause unpredictable behaviou…
…r. Temporary table functionality was substituted with subqueries in each use case.

ADDED: Test case for version archive functionality.
0f09305
@tractorcow tractorcow REMOVED: Unnecessary publish actions from test cases
ADDED: Test case for get_all_versions
56fe7f8
@tractorcow tractorcow UPDATED: Improved get_all_versions test case to test versions in the …
…middle of version updates.
89728ac
@travisbot

This pull request passes (merged 89728ac into 023721a).

@sminnee
Owner

Great!

@sminnee sminnee merged commit 47b56d4 into from
@tractorcow
Collaborator

I have added two additional Versioned test cases and checked that all Versioned test cases still pass.

Additionally, I noted an issue with the use of a temporary table when doing versioned queries. The issue would creep in when doing subsequent queries on the same table over different archived dates (as in my test cases). This was due to the rigidity of using a single temporary table over multiple queries.

It could have been fixed using the same method, but I opted instead of replace the temporary table model with a pair of similar (yet different) subquery conditions in each of the two places this function was called.

It could also have been implemented using a join on a derived table, but I wasn't certain how well the query library would handle an aliased query as a table name, so I erred in favour of a slightly more self contained correlated subquery.

@tractorcow
Collaborator

Thanks mate. :) You guys are pretty quick on the update!

@sminnee
Owner

I'm enjoying Travis' pre-tested pull requests - now I can read over the patch online and click the big green button, knowing there's no build failures. :-)

It's also good to see another dev debugging in the core for the ORM! Looking forward to future patches...

@tractorcow
Collaborator

As am I; It's a new feature isn't it?

Anyway, expect plenty more as I trudge through the Translatable migration. :)

@sminnee
Owner

Yeah - just set it up a few days ago.

@tazzydemon

I don't think this passes real world tests as in my thread http://www.silverstripe.org/general-questions/show/21283 and ticket 7975. There is, I believe an ID mixup which rears its head when ID and RecordID diverge. The subsequent effect on Hierarchy and CMSSiteTreeFilter is another matter.

@tractorcow
Collaborator

Thanks Tazzy for noting this; I'd like to jump into this but it's likely to be a while before I'm free.

Apologies for the bug as you noted above... it seems I often miss certain cases in my tests, my fault there.

I do highly disagree with one of the points you made in the thread above, however. One of the major changes in the above is moving away from the whole process around using temporary tables, so I'd still expect to find a solution that avoids this. Expressing a query that relies on the state of a particular table to be correct means it's not portable. augmentSQL only generates the query, and doesn't control the order in which queries are executed (or if it even is at all). Even if the process requires another layer of subquery then it would still be far more robust than using an unnecessary temp table.

Are you able to write a test case to demonstrate the error?

I think the code itself could also do with some additional documentation on the assumptions that versioned relies on (as you noted, for example, the relationship between RecordID and ID).

Sorry if any of the above is wrong or unresearched, as it's mostly off the top of my head, so it's possible I'm just spewing garbage. I'll take some time to properly research this and spot the bug in my code.

Thanks for taking the time to get back to me on this. :)

@sminnee
Owner

The temporary table thing was a bit of a hack, but a working hack does beat an elegant but broken solution. ;-)

At the time of the original SS2 implementation, trying to write it the code without a temporary table made my brain hurt. Of course, this was also a time when we didn't have unit tests, so we've come a long way since then.

@tazzydemon

Well I have never written a test so I will have to learn. The real world example is better in this case when you create a raft of pages and delete them forcing the counts to separate. In the case of Display All Pages Including Deleted these is the remaining problem that the site tree (IMHO) cant find the pages to display them.

As far as I can tell, either the query will have to be replaced entirely to get rid of ID or a function added to each schema.inc to removeField() to get rid of the duplicate ID

I have been buried in print_r $query-sql() lately. My primitive and old-guy (which I am) way of debugging. This problem has been perverse and I got adrexia to report it for me. I forgot at the time about trac. I work on symfony as well (or at least I did until today when a company merger occurred) so switching is always wierd.

I was getting quite despondent that I can't get anybody to respond to this except adrexia, so thank you for that. The takeover company is an MS house so my development of the new sites is on a knife edge and any instability would not be cool.

Try the Display All Pages including Deleted function button. You will find that when you delete a page it will appear as deleted, then then after a refresh. Poof. Gone!

@tractorcow
Collaborator

Well, if it makes you feel better, I'm directly responsible for this, so feel free to bug me all you need in order to get it fixed! I have a couple other rather serious bugs on my plate to fix, so this won't be too big an extra burden I suppose. :)

@tazzydemon

Tractorcow,

Thank you for your response. I fear this might go deeper than you imagine, in the way the cms site tree is displayed and which tables are used to populate the names. It isn't just the Versioned query. I kludged that and the sitetreefilter to see what would happen. I don't know enough to go deeper.

I first noticed this and other wierdness on a three-site subsites install along with the Swedish multilanguage module. I had some decoupling to do to prove it. In the end building several new bare installs. It first came to light when reordering pages by drag and drop and accidentally dropping them into another subsite during a short drag and drop "freeze" so to speak. Not sure of the mechanism there and its almost impossibleto reproduce deliberately. There's no way back from that, although I have suggested it to subsites maintainers. This led me to look at deleted pages as I was creating a few deleted pages from the wrong subsites. The result was unexpected and I have spent days and days analysing why!

@tazzydemon

Dear Tractorcow

Just an observation about your comment that I missed. I'm not sure I was advocating a return to a temporary table. But this, I think, was when the problem started, but not what was responsible.

However I have not established which SS3 version came just before this change or I would have done a checkout of that to compare. If you know then I will do just that. I appreciate that this is very tricky which is why I have spent a stupid amount of time proving it to myself.

@tractorcow
Collaborator

There is no such thing as a stupid amount of time spent when it's on open source code. It's all a part of the course. :) Once you get more into this you'll find that the most trivial of fixes often involve,

  1. Fixing the real world case
  2. Creating a fictional situation test case
  3. Fixing errors in the test case
  4. Fixing the test case in some obscure DB connector library
  5. Fixing that obscure connector error, with its own test case, and wait for it to be absorbed into master
  6. Finally fixing the error in the original problem
  7. Finally write the code that was blocked by your minor error
  8. Once you feel that all is resolved, some sneaky guy down the track notes another (real world / hypothetical, often both) situation where your code reaks havoc.
  9. GOTO step 1.

It's really quite fun at times, and is fulfilling to be able to contribute to something along with others.

I suggest you start by looking at the current test suite for versioned, and see how it is built. Once you understand how databases are able to be mocked (and it really is quite ingenous) you should be able to translate your real-world test situations into standard unit tests. Look at atomic transactions, and think about solving small problems, rather than big processes involving lots of human steps. The worst that could come out of it is that you develop a lot of test cases that prove the existing code already works.

Perhaps look at the assumption you'd like to test above. Do the current test cases assume a direct relationship between ID and RecordID? Perhaps a test case where these are randomly assigned would be a good candidate? You could possibly edit the existing test case with this assumption (with comments describing the testing scenario), without having to create some from scratch, or perhaps you could extend those existing tests with additional data.

Check the VersionedTest::testGetIncludingDeleted function. Are there some cases here that are not currently being tested by the code, but that come up in the real world?

I must sincerely apologise for writing all this up, but not actually doing any work on this myself. If I were less mentally lazy I'd spend some of my own time helping with this.

Once I get some time I'd actually like to go back to the issues I'm struggling with at #887. If you have a moment, would you be able to assist me with this? Postgres is causing me some serious issues (despite having rewritten the module personally >_>).

@tazzydemon

The more I work on this the harder it gets (and more embarrassing). I don't really need to look at the test suite. I can see it doesn't work quite clearly without that.

This is what I have done so far:

Versioned.php after L235

$query->selectField(sprintf('"%s_versions"."%s"', $baseTable, 'ID'), "DummyID");
$query->selectField(sprintf('"%s_versions"."%s"', $baseTable, 'RecordID'),sprintf('%s_versions.%s', $baseTable, 'ID'));
$query->addOrderBy(sprintf('"%s_versions"."%s"', $baseTable, 'Version'));

This is to fool the system into not seeing the ID and seeing recordID as ID. The code isn't perfect, especially the quotes in the second line. But it works and I can see deleted entries in the site tree.

Unfortunately it also didnt work... this is the result of a print id $child in Heirarchy.php L102

Page Object .....
(

[record:protected] => Array
    (
        [ClassName] => Page
        [Created] => 2012-11-07 11:08:28
        [LastEdited] => 2012-11-07 11:08:36
        [URLSegment] => tbd
        [Title] => tbd
        [ShowInMenus] => 1
        [ShowInSearch] => 1
        [Sort] => 6
        [HasBrokenFile] => 0
        [HasBrokenLink] => 0
        [CanViewType] => Inherit
        [CanEditType] => Inherit
        [Version] => 2
        [ParentID] => 0
        [ID] => 585
        [RecordClassName] => Page
        [RecordID] => 29
        [WasPublished] => 1
        [AuthorID] => 1
        [PublisherID] => 1
        [DummyID] => 585
        [SiteTree_versions.ID] => 29
        [DropInContent_Lazy] => Page
        [MenuExcerpt_Lazy] => Page
        [DropInLinkID_Lazy] => Page
        [DropInImageID_Lazy] => Page
    )

So I failed to swop the ID for the new SiteTree_versions.ID and no other method works. I was being a bit optimistic ( and desperate) there. I need a way of recreating this query from scratch. This is the $child that's being rendered in forTemplate()

Some more experimental changes

LeftandMain.php L724

$titleFn = function(&$child) use(&$controller, &$recordController, $childrenMethod) {

    $useID = ($childrenMethod == 'AllHistoricalChildren')? $child->RecordID :$child->ID;
$link = Controller::join_links($recordController->Link("show"), $useID);
return LeftAndMain_TreeNode::create($child, $link, $controller->isCurrentPage($child), $childrenMethod)->forTemplate();

Notice I added $childrenMethod as an arg and switched the ID. I also added $childrenMethod as a call to forTemplate().

Also in LeftandMain.php but this time treenode

public function __construct($obj, $link = null, $isCurrent = false, $childrenMethod = null) {
$this->obj = $obj;
$this->link = $link;
$this->isCurrent = $isCurrent;
$this->childrenMethod = $childrenMethod;
}

public function forTemplate() {
$obj = $this->obj;
$useID = ($this->childrenMethod == 'AllHistoricalChildren')? $obj->RecordID :$obj->ID;
return "

  • ClassName\" class=\""

    The snag is that although this is on the right track, none of it works because this notion of ID is everywhere, save() and heaven knows what besides. The real problem starts with the way the table xxx_versions is used with its ID instead of Record ID. Perhaps the temp table was a good idea after all!

  • @tazzydemon

    It dawns on me that perhaps the simplest solution might be the best. Why not just move deleted pages to a SiteTree_deleted table (with a correct ID of course!) With or without versioning. Is versioning required on a deleted page? Arguable. This way the working tables would be purged as well.

    @tractorcow
    Collaborator

    Hi Tazzy, I've moved the discussion for this issue to the ticket raised at http://open.silverstripe.org/ticket/7975

    @tazzydemon
    @tractorcow
    Collaborator

    Hi Julian, I hope you're feeling better, and I trust that your health is well. Don't let the scary code ruin your Christmas! :)

    Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
    Commits on Aug 10, 2012
    1. @tractorcow

      FIXED: Issue where viewing an archived version of a page caused inval…

      tractorcow authored
      …id SQL to be generated. This would only occur with subclasses of Page.
    2. @tractorcow

      FIXED: Issue where versioned would join _versions tables on ID,Versio…

      tractorcow authored
      …n instead of RecordID,Version
    Commits on Aug 20, 2012
    1. @tractorcow

      FIXED: Issue where temporary table would cause unpredictable behaviou…

      tractorcow authored
      …r. Temporary table functionality was substituted with subqueries in each use case.
      
      ADDED: Test case for version archive functionality.
    2. @tractorcow

      REMOVED: Unnecessary publish actions from test cases

      tractorcow authored
      ADDED: Test case for get_all_versions
    3. @tractorcow
    This page is out of date. Refresh to see the latest.
    Showing with 130 additions and 37 deletions.
    1. +27 −37 model/Versioned.php
    2. +103 −0 tests/model/VersionedTest.php
    View
    64 model/Versioned.php
    @@ -145,7 +145,8 @@ function augmentSQL(SQLQuery &$query, DataQuery &$dataQuery = null) {
    $date = $dataQuery->getQueryParam('Versioned.date');
    foreach($query->getFrom() as $table => $dummy) {
    $query->renameTable($table, $table . '_versions');
    - $query->replaceText("\"$table\".\"ID\"", "\"$table\".\"RecordID\"");
    + $query->replaceText("\"{$table}_versions\".\"ID\"", "\"{$table}_versions\".\"RecordID\"");
    + $query->replaceText("`{$table}_versions`.`ID`", "`{$table}_versions`.`RecordID`");
    // Add all <basetable>_versions columns
    foreach(self::$db_for_versions_table as $name => $type) {
    @@ -154,15 +155,24 @@ function augmentSQL(SQLQuery &$query, DataQuery &$dataQuery = null) {
    $query->selectField(sprintf('"%s_versions"."%s"', $baseTable, 'RecordID'), "ID");
    if($table != $baseTable) {
    - $query->addFrom(array($table => " AND \"{$table}_versions\".\"Version\" = \"{$baseTable}_versions\".\"Version\""));
    + $query->addWhere("\"{$table}_versions\".\"Version\" = \"{$baseTable}_versions\".\"Version\"");
    }
    }
    // Link to the version archived on that date
    - $archiveTable = $this->requireArchiveTempTable($baseTable, $date);
    - $query->addFrom(array($archiveTable => "INNER JOIN \"$archiveTable\"
    - ON \"$archiveTable\".\"ID\" = \"{$baseTable}_versions\".\"RecordID\"
    - AND \"$archiveTable\".\"Version\" = \"{$baseTable}_versions\".\"Version\""));
    + $safeDate = Convert::raw2sql($date);
    + $query->addWhere(
    + "`{$baseTable}_versions`.`Version` IN
    + (SELECT LatestVersion FROM
    + (SELECT
    + `{$baseTable}_versions`.`RecordID`,
    + MAX(`{$baseTable}_versions`.`Version`) AS LatestVersion
    + FROM `{$baseTable}_versions`
    + WHERE `{$baseTable}_versions`.`LastEdited` <= '$safeDate'
    + GROUP BY `{$baseTable}_versions`.`RecordID`
    + ) AS `{$baseTable}_versions_latest`
    + WHERE `{$baseTable}_versions_latest`.`RecordID` = `{$baseTable}_versions`.`RecordID`
    + )");
    break;
    // Reading a specific stage (Stage or Live)
    @@ -203,10 +213,18 @@ function augmentSQL(SQLQuery &$query, DataQuery &$dataQuery = null) {
    // Return latest version instances, regardless of whether they are on a particular stage
    // This provides "show all, including deleted" functonality
    if($dataQuery->getQueryParam('Versioned.mode') == 'latest_versions') {
    - $archiveTable = self::requireArchiveTempTable($baseTable);
    - $query->addInnerJoin($archiveTable, "\"$archiveTable\".\"ID\" = \"{$baseTable}_versions\".\"RecordID\" AND \"$archiveTable\".\"Version\" = \"{$baseTable}_versions\".\"Version\"");
    + $query->addWhere(
    + "`{$alias}_versions`.`Version` IN
    + (SELECT LatestVersion FROM
    + (SELECT
    + `{$alias}_versions`.`RecordID`,
    + MAX(`{$alias}_versions`.`Version`) AS LatestVersion
    + FROM `{$alias}_versions`
    + GROUP BY `{$alias}_versions`.`RecordID`
    + ) AS `{$alias}_versions_latest`
    + WHERE `{$alias}_versions_latest`.`RecordID` = `{$alias}_versions`.`RecordID`
    + )");
    }
    -
    break;
    default:
    throw new InvalidArgumentException("Bad value for query parameter Versioned.mode: " . $dataQuery->getQueryParam('Versioned.mode'));
    @@ -233,34 +251,6 @@ public static function on_db_reset() {
    // Remove references to them
    self::$archive_tables = array();
    }
    -
    - /**
    - * Create a temporary table mapping each database record to its version on the given date.
    - * This is used by the versioning system to return database content on that date.
    - * @param string $baseTable The base table.
    - * @param string $date The date. If omitted, then the latest version of each page will be returned.
    - * @todo Ensure that this is DB abstracted
    - */
    - protected static function requireArchiveTempTable($baseTable, $date = null) {
    - if(!isset(self::$archive_tables[$baseTable])) {
    - self::$archive_tables[$baseTable] = DB::createTable("_Archive$baseTable", array(
    - "ID" => "INT NOT NULL",
    - "Version" => "INT NOT NULL",
    - ), null, array('temporary' => true));
    - }
    -
    - if(!DB::query("SELECT COUNT(*) FROM \"" . self::$archive_tables[$baseTable] . "\"")->value()) {
    - if($date) $dateClause = "WHERE \"LastEdited\" <= '$date'";
    - else $dateClause = "";
    -
    - DB::query("INSERT INTO \"" . self::$archive_tables[$baseTable] . "\"
    - SELECT \"RecordID\", max(\"Version\") FROM \"{$baseTable}_versions\"
    - $dateClause
    - GROUP BY \"RecordID\"");
    - }
    -
    - return self::$archive_tables[$baseTable];
    - }
    /**
    * An array of DataObject extensions that may require versioning for extra tables
    View
    103 tests/model/VersionedTest.php
    @@ -266,6 +266,109 @@ public function testGetVersionWhenClassnameChanged() {
    $this->assertInstanceOf("VersionedTest_DataObject", $obj3);
    }
    +
    + public function testArchiveVersion() {
    +
    + // In 2005 this file was created
    + SS_Datetime::set_mock_now('2005-01-01 00:00:00');
    + $testPage = new VersionedTest_Subclass();
    + $testPage->Title = 'Archived page';
    + $testPage->Content = 'This is the content from 2005';
    + $testPage->ExtraField = '2005';
    + $testPage->write();
    +
    + // In 2007 we updated it
    + SS_Datetime::set_mock_now('2007-01-01 00:00:00');
    + $testPage->Content = "It's 2007 already!";
    + $testPage->ExtraField = '2007';
    + $testPage->write();
    +
    + // In 2009 we updated it again
    + SS_Datetime::set_mock_now('2009-01-01 00:00:00');
    + $testPage->Content = "I'm enjoying 2009";
    + $testPage->ExtraField = '2009';
    + $testPage->write();
    +
    + // End mock, back to the present day:)
    + SS_Datetime::clear_mock_now();
    +
    + // Test 1 - 2006 Content
    + singleton('VersionedTest_Subclass')->flushCache(true);
    + Versioned::set_reading_mode('Archive.2006-01-01 00:00:00');
    + $testPage2006 = DataObject::get('VersionedTest_Subclass')->filter(array('Title' => 'Archived page'))->first();
    + $this->assertInstanceOf("VersionedTest_Subclass", $testPage2006);
    + $this->assertEquals("2005", $testPage2006->ExtraField);
    + $this->assertEquals("This is the content from 2005", $testPage2006->Content);
    +
    + // Test 2 - 2008 Content
    + singleton('VersionedTest_Subclass')->flushCache(true);
    + Versioned::set_reading_mode('Archive.2008-01-01 00:00:00');
    + $testPage2008 = DataObject::get('VersionedTest_Subclass')->filter(array('Title' => 'Archived page'))->first();
    + $this->assertInstanceOf("VersionedTest_Subclass", $testPage2008);
    + $this->assertEquals("2007", $testPage2008->ExtraField);
    + $this->assertEquals("It's 2007 already!", $testPage2008->Content);
    +
    + // Test 3 - Today
    + singleton('VersionedTest_Subclass')->flushCache(true);
    + Versioned::set_reading_mode('Stage.Stage');
    + $testPageCurrent = DataObject::get('VersionedTest_Subclass')->filter(array('Title' => 'Archived page'))->first();
    + $this->assertInstanceOf("VersionedTest_Subclass", $testPageCurrent);
    + $this->assertEquals("2009", $testPageCurrent->ExtraField);
    + $this->assertEquals("I'm enjoying 2009", $testPageCurrent->Content);
    + }
    +
    + public function testAllVersions()
    + {
    + // In 2005 this file was created
    + SS_Datetime::set_mock_now('2005-01-01 00:00:00');
    + $testPage = new VersionedTest_Subclass();
    + $testPage->Title = 'Archived page';
    + $testPage->Content = 'This is the content from 2005';
    + $testPage->ExtraField = '2005';
    + $testPage->write();
    +
    + // In 2007 we updated it
    + SS_Datetime::set_mock_now('2007-01-01 00:00:00');
    + $testPage->Content = "It's 2007 already!";
    + $testPage->ExtraField = '2007';
    + $testPage->write();
    +
    + // Check both versions are returned
    + $versions = Versioned::get_all_versions('VersionedTest_Subclass', $testPage->ID);
    + $content = array();
    + $extraFields = array();
    + foreach($versions as $version)
    + {
    + $content[] = $version->Content;
    + $extraFields[] = $version->ExtraField;
    + }
    +
    + $this->assertEquals($versions->Count(), 2, 'All versions returned');
    + $this->assertEquals($content, array('This is the content from 2005', "It's 2007 already!"), 'Version fields returned');
    + $this->assertEquals($extraFields, array('2005', '2007'), 'Version fields returned');
    +
    + // In 2009 we updated it again
    + SS_Datetime::set_mock_now('2009-01-01 00:00:00');
    + $testPage->Content = "I'm enjoying 2009";
    + $testPage->ExtraField = '2009';
    + $testPage->write();
    +
    + // End mock, back to the present day:)
    + SS_Datetime::clear_mock_now();
    +
    + $versions = Versioned::get_all_versions('VersionedTest_Subclass', $testPage->ID);
    + $content = array();
    + $extraFields = array();
    + foreach($versions as $version)
    + {
    + $content[] = $version->Content;
    + $extraFields[] = $version->ExtraField;
    + }
    +
    + $this->assertEquals($versions->Count(), 3, 'Additional all versions returned');
    + $this->assertEquals($content, array('This is the content from 2005', "It's 2007 already!", "I'm enjoying 2009"), 'Additional version fields returned');
    + $this->assertEquals($extraFields, array('2005', '2007', '2009'), 'Additional version fields returned');
    + }
    }
    class VersionedTest_DataObject extends DataObject implements TestOnly {
    Something went wrong with that request. Please try again.