Skip to content

Instantly share code, notes, and snippets.

@NightJar
Created May 26, 2021 04:57
Show Gist options
  • Save NightJar/30c17f80e6b587dbe1dd32e2a9ef5965 to your computer and use it in GitHub Desktop.
Save NightJar/30c17f80e6b587dbe1dd32e2a9ef5965 to your computer and use it in GitHub Desktop.
Repair existing snapshots which have erroneously not recorded their author
<?php
namespace App\Tests\Migrations\Stubs;
use SilverStripe\Dev\TestOnly;
use SilverStripe\ORM\DataObject;
class ContentBit extends DataObject implements TestOnly
{
private static $table_name = 'BitOfContent';
private static $has_one = ['Byte' => ContentByte::class];
}
<?php
namespace App\Tests\Migrations\Stubs;
use SilverStripe\Dev\TestOnly;
class ContentBitVideo extends ContentBit implements TestOnly
{
}
<?php
namespace App\Tests\Migrations\Stubs;
use SilverStripe\Dev\TestOnly;
use SilverStripe\ORM\DataObject;
class ContentByte extends DataObject implements TestOnly
{
private static $table_name = 'ByteOfContent';
private static $has_many = ['Bits' => ContentBit::class];
private static $owns = ['Bits'];
}
<?php
namespace App\Tests\Migrations\Stubs;
use SilverStripe\Dev\TestOnly;
use SilverStripe\ORM\DataObject;
class ContentWord extends DataObject implements TestOnly
{
private static $table_name = 'WordOfContent';
private static $has_one = ['Byte' => ContentByte::class];
private static $owns = ['Byte'];
}
<?php
namespace App\Tests\Migrations\Stubs;
use SilverStripe\Dev\TestOnly;
use SilverStripe\ORM\DataObject;
use SilverStripe\Security\Member;
class VersionBit extends DataObject implements TestOnly
{
private static $table_name = 'BitOfContent_Versions';
private static $db = [
'Version' => 'Int',
'WasPublished' => 'Boolean',
'WasDeleted' => 'Boolean',
'WasDraft' => 'Boolean',
];
private static $has_one = [
'Record' => ContentBit::class,
'Author' => Member::class,
'Publisher' => Member::class,
'Byte' => ContentByte::class,
];
}
<?php
namespace App\Tests\Migrations\Stubs;
use SilverStripe\Dev\TestOnly;
use SilverStripe\ORM\DataObject;
use SilverStripe\Security\Member;
class VersionByte extends DataObject implements TestOnly
{
private static $table_name = 'ByteOfContent_Versions';
private static $db = [
'Version' => 'Int',
'WasPublished' => 'Boolean',
'WasDeleted' => 'Boolean',
'WasDraft' => 'Boolean',
];
private static $has_one = [
'Record' => ContentByte::class,
'Author' => Member::class,
'Publisher' => Member::class,
];
}
<?php
namespace App\Migrations;
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
use Psr\Log\LoggerInterface;
use SilverStripe\Control\Director;
use SilverStripe\Core\Convert;
use SilverStripe\Dev\BuildTask;
use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\DB;
use SilverStripe\ORM\Queries\SQLSelect;
use SilverStripe\Snapshots\Snapshot;
use SilverStripe\Snapshots\SnapshotItem;
/**
* Copies information from ChangeSet records to VersionedSnapshot records to retain CMS visible information after an
* upgrade. See https://github.com/silverstripe/silverstripe-versioned-snapshot-admin/issues/51
*/
class VersionedSnapshotsAuthorIntegrityTask extends BuildTask
{
protected $title = 'Ensure an author is correctly set for snapshots used for history auditing';
protected $description = 'Versioned snapshots used to have a bug where authors were not correctly recorded, as the'
. ' system relied on the name in the most recent ChangeSet. This task copies the changeset author name across'
. ' to existing snapshots, as so when the snapshot update to use this data instead of changeset info no data'
. ' is lost. See https://github.com/silverstripe/silverstripe-versioned-snapshot-admin/issues/51';
/**
* @var LoggerInterface
*/
private $logger;
private static $segment = 'SnapshotAuthorFix';
private static $dependencies = [
'Logger' => '%$' . LoggerInterface::class . '.' . self::class,
];
/**
* @inheritDoc
* @codeCoverageIgnore This is (or should be) a run-once-and-delete task
*/
public function run($request)
{
$this->addLogHandlers();
$totalBad = 0;
$totalFixed = 0;
$authorlessSnapshotClasses = $this->getAuthorlessSnapshotDetails();
$schema = DataObject::getSchema();
$snapshot = $schema->baseDataTable(Snapshot::class);
$snapshotId = $schema->sqlColumnForField(Snapshot::class, 'ID');
$snapshotHash = $schema->sqlColumnForField(Snapshot::class, 'OriginHash');
$snapshotAuthor = $schema->sqlColumnForField(Snapshot::class, 'AuthorID');
$snapshotOriginId = $schema->sqlColumnForField(Snapshot::class, 'OriginID');
$snapshotOriginClass = $schema->sqlColumnForField(Snapshot::class, 'OriginClass');
$snapshotItem = $schema->baseDataTable(SnapshotItem::class);
$snapshotItemSnapshot = $schema->sqlColumnForField(SnapshotItem::class, 'SnapshotID');
$snapshotItemHash = $schema->sqlColumnForField(SnapshotItem::class, 'ObjectHash');
$snapshotItemVersion = $schema->sqlColumnForField(SnapshotItem::class, 'Version');
foreach ($authorlessSnapshotClasses as $originClass => $numberBad) {
$this->report($numberBad . ' snapshots missing authors for ' . $originClass);
if (!class_exists($originClass)) {
$this->report(
$originClass . ' no longer exists; cannot obtain table information for setting authors',
Logger::WARNING
);
continue;
}
$totalBad += $numberBad;
// the `_Versions` suffix is hardcoded in {@see Versioned}
$originVersion = $schema->baseDataTable($originClass) . '_Versions';
$originVersionVersion = '"' . $originVersion . '"."Version"';
$originVersionRecordId = '"' . $originVersion . '"."RecordID"';
$originVersionAuthor = '"' . $originVersion . '"."PublisherID"';
$rawQueryBecauseSQLUpdateClassDoesNotSupportJoins = 'UPDATE ' . $snapshot . "\n"
. 'LEFT JOIN ' . $snapshotItem . "\n"
. ' ON ' . $snapshotId . ' = ' . $snapshotItemSnapshot . "\n"
. 'LEFT JOIN ' . $originVersion . "\n"
. ' ON ' . $snapshotOriginId . ' = ' . $originVersionRecordId . "\n"
. 'SET ' . $snapshotAuthor . ' = ' . $originVersionAuthor . "\n"
. 'WHERE ' . $snapshotAuthor . " = 0\n"
. ' AND ' . $originVersionRecordId . " IS NOT NULL\n"
. ' AND ' . $snapshotHash . ' = ' . $snapshotItemHash . "\n"
. ' AND ' . $snapshotItemVersion . ' = ' . $originVersionVersion . "\n"
. ' AND ' . $snapshotOriginClass . " = '" . Convert::raw2sql($originClass) . "'";
// Luckily there is 0 user input, we're copying already inputted data from one table to another.
DB::query($rawQueryBecauseSQLUpdateClassDoesNotSupportJoins);
$updatedRecords = DB::affected_rows();
$totalFixed += $updatedRecords;
$remaining = $numberBad - $updatedRecords;
$this->report(sprintf('Fixed %s - %s malformed records for %s', $updatedRecords, $remaining, $originClass));
}
$this->report(sprintf('Fixed %s out of %s malformed records.', $totalFixed, $totalBad));
if ($totalBad - $totalFixed !== 0) {
$this->report(
sprintf(
'%s bad records for %s - there could be an undetected bug still logging'
. ' snapshots with no author set (this task only fixes `Versioned` initiated actions such as'
. ' save/publish), or maybe there was no author (system generated publishes, etc.)',
$remaining,
$originClass
),
Logger::WARNING
);
}
}
public function setLogger(LoggerInterface $logger): self
{
$this->logger = $logger;
return $this;
}
protected function addLogHandlers()
{
$logger = $this->logger;
if ($logger && Director::is_cli()) {
$logger->pushHandler(new StreamHandler('php://stdout'));
$logger->pushHandler(new StreamHandler('php://stderr', Logger::WARNING));
}
}
/**
* Logs a message
*
* @return void
*/
private function report(string $line, int $level = Logger::NOTICE)
{
$logger = $this->logger;
if (!$logger) {
return;
}
switch ($level) {
case Logger::WARNING:
$logger->warning($line);
break;
default:
$logger->notice($line);
}
}
/**
* Returns list of affected classes (all base class due to nature of Versioned Snapshots) and their affected count
*
* @return string[]
*/
private function getAuthorlessSnapshotDetails()
{
$schema = DataObject::getSchema();
$snapshotTable = $schema->baseDataTable(Snapshot::class);
$snapshotClassColumn = $schema->sqlColumnForField(Snapshot::class, 'OriginClass');
$snapshotAuthorColumn = $schema->sqlColumnForField(Snapshot::class, 'AuthorID');
$authorlessSnapshots = SQLSelect::create(
[
'ClassName' => $snapshotClassColumn,
'Count' => 'count(' . $snapshotClassColumn . ')',
],
$snapshotTable,
[$snapshotAuthorColumn => 0],
null,
$snapshotClassColumn
);
$classesToFix = [];
foreach ($authorlessSnapshots->execute() as $authorlessDetail) {
$classesToFix[$authorlessDetail['ClassName']] = $authorlessDetail['Count'];
}
return $classesToFix;
}
}
<?php
namespace App\Tests\Migrations;
use App\Migrations\VersionedSnapshotsAuthorIntegrityTask;
use App\Tests\Migrations\Stubs\ContentBit;
use App\Tests\Migrations\Stubs\ContentBitVideo;
use App\Tests\Migrations\Stubs\ContentByte;
use App\Tests\Migrations\Stubs\ContentWord;
use App\Tests\Migrations\Stubs\VersionBit;
use App\Tests\Migrations\Stubs\VersionByte;
use App\Tests\Migrations\Stubs\VersionWord;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Dev\SapphireTest;
use SilverStripe\Snapshots\Snapshot;
use SilverStripe\Versioned\Versioned;
class VersionedSnapshotsAuthorIntegrityTaskTest extends SapphireTest
{
protected static $fixture_file = 'VersionedSnapshotsAuthorIntegrityTaskTest.yml';
protected static $extra_dataobjects = [
ContentBit::class,
ContentBitVideo::class,
ContentByte::class,
ContentWord::class,
VersionBit::class,
VersionByte::class,
VersionWord::class,
];
protected static $required_extensions = [
ContentBit::class => [Versioned::class],
ContentByte::class => [Versioned::class],
ContentWord::class => [Versioned::class],
];
/**
* Ensure the task does as it should
*
* @return void
*/
public function testAuthorlessSnapshotsBecomeAuthored()
{
$repairTask = new VersionedSnapshotsAuthorIntegrityTask();
$request = new HTTPRequest('GET', 'dev/tasks/SnapshotAuthorFix');
$authorlessSnapshots = Snapshot::get()->filter('AuthorID', 0);
$this->assertSame(6, $authorlessSnapshots->count());
$repairTask->run($request);
$this->assertSame(0, $authorlessSnapshots->count());
}
/**
* DataObject writes by default update LastEdited, etc. which would corrupt history auditing in this case.
*
* @return void
*/
public function testDatesOfRepairedSnapshotsAreNotAffected()
{
$repairTask = new VersionedSnapshotsAuthorIntegrityTask();
$request = new HTTPRequest('GET', 'dev/tasks/SnapshotAuthorFix');
$snapshotDates = Snapshot::get()->column('LastEdited');
$repairTask->run($request);
$this->assertSame($snapshotDates, Snapshot::get()->column('LastEdited'));
}
/**
* Ensure we only affect the snapshots we intend to
*
* @return void
*/
public function testAuthorsOfUnaffectedSnapshotsAreNotErroneouslyCorrected()
{
$repairTask = new VersionedSnapshotsAuthorIntegrityTask();
$request = new HTTPRequest('GET', 'dev/tasks/SnapshotAuthorFix');
$authorlessSnapshotIDs = Snapshot::get()
->filter('AuthorID', 0)
->column('ID');
$existingAuthors = Snapshot::get()
->exclude('AuthorID', 0)
->column('AuthorID');
$repairTask->run($request);
$hopefullyUnaffectedExistingAuthors = Snapshot::get()
->exclude('ID', $authorlessSnapshotIDs)
->column('AuthorID');
$this->assertSame($existingAuthors, $hopefullyUnaffectedExistingAuthors);
}
}
SilverStripe\Security\Member:
admin:
FirstName: Site
Surname: Admin
email: admin@example.com
author:
FirstName: Author
Surname: "O'Content"
email: author@example.com
App\Tests\Migrations\Stubs\ContentByte:
area:
App\Tests\Migrations\Stubs\ContentWord:
page:
Byte: =>App\Tests\Migrations\Stubs\ContentByte.area
App\Tests\Migrations\Stubs\ContentBit:
content:
Byte: =>App\Tests\Migrations\Stubs\ContentByte.area
App\Tests\Migrations\Stubs\ContentBitVideo:
first:
Byte: =>App\Tests\Migrations\Stubs\ContentByte.area
second:
Byte: =>App\Tests\Migrations\Stubs\ContentByte.area
SilverStripe\Snapshots\Snapshot:
one:
OriginHash: ad671cb0fee3f44de935cd3edbbcfae7
Origin: =>App\Tests\Migrations\Stubs\ContentWord.page
two:
OriginHash: 6121a8222606980635054b7dd9daa8c1
Author: =>SilverStripe\Security\Member.admin
Origin: =>App\Tests\Migrations\Stubs\ContentBitVideo.first
three:
OriginHash: 6121a8222606980635054b7dd9daa8c1
Origin: =>App\Tests\Migrations\Stubs\ContentBitVideo.first
four:
OriginHash: 6121a8222606980635054b7dd9daa8c1
Origin: =>App\Tests\Migrations\Stubs\ContentBitVideo.first
five:
OriginHash: 6121a8222606980635054b7dd9daa8c1
Author: =>SilverStripe\Security\Member.admin
Origin: =>App\Tests\Migrations\Stubs\ContentBitVideo.first
six:
OriginHash: 6121a8222606980635054b7dd9daa8c1
Author: =>SilverStripe\Security\Member.admin
Origin: =>App\Tests\Migrations\Stubs\ContentBitVideo.first
seven:
OriginHash: ad671cb0fee3f44de935cd3edbbcfae7
Author: =>SilverStripe\Security\Member.admin
Origin: =>App\Tests\Migrations\Stubs\ContentWord.page
eight:
OriginHash: ad671cb0fee3f44de935cd3edbbcfae7
Origin: =>App\Tests\Migrations\Stubs\ContentWord.page
nine:
OriginHash: 6121a8222606980635054b7dd9daa8c1
Author: =>SilverStripe\Security\Member.author
Origin: =>App\Tests\Migrations\Stubs\ContentBitVideo.first
ten:
OriginHash: ad671cb0fee3f44de935cd3edbbcfae7
Author: =>SilverStripe\Security\Member.author
Origin: =>App\Tests\Migrations\Stubs\ContentWord.page
eleven:
OriginHash: 7942bbe24fb5701554459ac4e78f83e3
Origin: =>App\Tests\Migrations\Stubs\ContentBitVideo.second
twelve:
OriginHash: ad671cb0fee3f44de935cd3edbbcfae7
Origin: =>App\Tests\Migrations\Stubs\ContentWord.page
thirteen:
OriginHash: ad671cb0fee3f44de935cd3edbbcfae7
Author: =>SilverStripe\Security\Member.admin
Origin: =>App\Tests\Migrations\Stubs\ContentWord.page
SilverStripe\Snapshots\SnapshotItem:
one:
Version: 6
WasPublished: 1
Snapshot: =>SilverStripe\Snapshots\Snapshot.one
ObjectHash: ad671cb0fee3f44de935cd3edbbcfae7
Object: =>App\Tests\Migrations\Stubs\ContentWord.page
two:
Version: 5
WasDraft: 1
Snapshot: =>SilverStripe\Snapshots\Snapshot.two
ObjectHash: 6121a8222606980635054b7dd9daa8c1
Object: =>App\Tests\Migrations\Stubs\ContentBitVideo.first
three:
Version: 7
WasPublished: 1
Snapshot: =>SilverStripe\Snapshots\Snapshot.three
ObjectHash: 6121a8222606980635054b7dd9daa8c1
Object: =>App\Tests\Migrations\Stubs\ContentBitVideo.first
four:
Version: 9
WasPublished: 1
Snapshot: =>SilverStripe\Snapshots\Snapshot.four
ObjectHash: 6121a8222606980635054b7dd9daa8c1
Object: =>App\Tests\Migrations\Stubs\ContentBitVideo.first
five:
Version: 10
WasDraft: 1
Snapshot: =>SilverStripe\Snapshots\Snapshot.five
ObjectHash: 6121a8222606980635054b7dd9daa8c1
Object: =>App\Tests\Migrations\Stubs\ContentBitVideo.first
six:
Version: 11
WasDraft: 1
Snapshot: =>SilverStripe\Snapshots\Snapshot.six
ObjectHash: 6121a8222606980635054b7dd9daa8c1
Object: =>App\Tests\Migrations\Stubs\ContentBitVideo.first
seven:
Version: 7
WasDraft: 1
Snapshot: =>SilverStripe\Snapshots\Snapshot.seven
ObjectHash: ad671cb0fee3f44de935cd3edbbcfae7
Object: =>App\Tests\Migrations\Stubs\ContentWord.page
eight:
Version: 8
WasPublished: 1
Snapshot: =>SilverStripe\Snapshots\Snapshot.eight
ObjectHash: ad671cb0fee3f44de935cd3edbbcfae7
Object: =>App\Tests\Migrations\Stubs\ContentWord.page
nine:
Version: 13
WasDraft: 1
Snapshot: =>SilverStripe\Snapshots\Snapshot.nine
ObjectHash: 6121a8222606980635054b7dd9daa8c1
Object: =>App\Tests\Migrations\Stubs\ContentBitVideo.first
ten:
Version: 9
WasDraft: 1
Snapshot: =>SilverStripe\Snapshots\Snapshot.ten
ObjectHash: ad671cb0fee3f44de935cd3edbbcfae7
Object: =>App\Tests\Migrations\Stubs\ContentWord.page
eleven:
Version: 6
WasPublished: 1
Snapshot: =>SilverStripe\Snapshots\Snapshot.eleven
ObjectHash: 7942bbe24fb5701554459ac4e78f83e3
Object: =>App\Tests\Migrations\Stubs\ContentBitVideo.second
twelve:
Version: 10
WasPublished: 1
Snapshot: =>SilverStripe\Snapshots\Snapshot.twelve
ObjectHash: ad671cb0fee3f44de935cd3edbbcfae7
Object: =>App\Tests\Migrations\Stubs\ContentWord.page
thirteen:
Version: 10
Snapshot: =>SilverStripe\Snapshots\Snapshot.thirteen
WasDraft: 1
WasUnpublished: 1
ObjectHash: ad671cb0fee3f44de935cd3edbbcfae7
Object: =>App\Tests\Migrations\Stubs\ContentWord.page
App\Tests\Migrations\Stubs\VersionWord:
one:
Record: =>App\Tests\Migrations\Stubs\ContentWord.page
Version: 6
WasPublished: 1
WasDraft: 1
Author: =>SilverStripe\Security\Member.admin
Publisher: =>SilverStripe\Security\Member.admin
ClassName: App\Tests\Migrations\Stubs\ContentWord=
two:
Record: =>App\Tests\Migrations\Stubs\ContentWord.page
Version: 7
WasDraft: 1
Author: =>SilverStripe\Security\Member.admin
ClassName: App\Tests\Migrations\Stubs\ContentWord
three:
Record: =>App\Tests\Migrations\Stubs\ContentWord.page
Version: 8
WasPublished: 1
WasDraft: 1
Author: =>SilverStripe\Security\Member.admin
Publisher: =>SilverStripe\Security\Member.admin
ClassName: App\Tests\Migrations\Stubs\ContentWord
four:
Record: =>App\Tests\Migrations\Stubs\ContentWord.page
Version: 9
WasDraft: 1
Author: =>SilverStripe\Security\Member.author
ClassName: App\Tests\Migrations\Stubs\ContentWord
five:
Record: =>App\Tests\Migrations\Stubs\ContentWord.page
Version: 10
WasPublished: 1
WasDraft: 1
Author: =>SilverStripe\Security\Member.admin
Publisher: =>SilverStripe\Security\Member.admin
ClassName: App\Tests\Migrations\Stubs\ContentWord
six:
Record: =>App\Tests\Migrations\Stubs\ContentWord.page
Version: 10
WasPublished: 1
WasDraft: 1
Author: =>SilverStripe\Security\Member.admin
Publisher: =>SilverStripe\Security\Member.admin
ClassName: App\Tests\Migrations\Stubs\ContentWord
App\Tests\Migrations\Stubs\VersionBit:
one:
Record: =>App\Tests\Migrations\Stubs\ContentBitVideo.first
Version: 5
WasDraft: 1
Author: =>SilverStripe\Security\Member.admin
ClassName: App\Tests\Migrations\Stubs\ContentBitVideo
two:
Record: =>App\Tests\Migrations\Stubs\ContentBitVideo.first
Version: 7
WasPublished: 1
WasDraft: 1
Author: =>SilverStripe\Security\Member.admin
Publisher: =>SilverStripe\Security\Member.admin
ClassName: App\Tests\Migrations\Stubs\ContentBitVideo
three:
Record: =>App\Tests\Migrations\Stubs\ContentBitVideo.first
Version: 9
WasPublished: 1
WasDraft: 1
Author: =>SilverStripe\Security\Member.admin
Publisher: =>SilverStripe\Security\Member.admin
ClassName: App\Tests\Migrations\Stubs\ContentBitVideo
four:
Record: =>App\Tests\Migrations\Stubs\ContentBitVideo.first
Version: 10
WasDraft: 1
Author: =>SilverStripe\Security\Member.admin
ClassName: App\Tests\Migrations\Stubs\ContentBitVideo
five:
Record: =>App\Tests\Migrations\Stubs\ContentBitVideo.first
Version: 11
WasDraft: 1
Author: =>SilverStripe\Security\Member.admin
ClassName: App\Tests\Migrations\Stubs\ContentBitVideo
six:
Record: =>App\Tests\Migrations\Stubs\ContentBitVideo.first
Version: 13
WasDraft: 1
Author: =>SilverStripe\Security\Member.author
ClassName: App\Tests\Migrations\Stubs\ContentBitVideo
seven:
Record: =>App\Tests\Migrations\Stubs\ContentBitVideo.second
Version: 6
WasPublished: 1
WasDraft: 1
Author: =>SilverStripe\Security\Member.author
Publisher: =>SilverStripe\Security\Member.author
ClassName: App\Tests\Migrations\Stubs\ContentBitVideo
<?php
namespace App\Tests\Migrations\Stubs;
use SilverStripe\Dev\TestOnly;
use SilverStripe\ORM\DataObject;
use SilverStripe\Security\Member;
class VersionWord extends DataObject implements TestOnly
{
private static $table_name = 'WordOfContent_Versions';
private static $db = [
'Version' => 'Int',
'WasPublished' => 'Boolean',
'WasDeleted' => 'Boolean',
'WasDraft' => 'Boolean',
];
private static $has_one = [
'Record' => ContentWord::class,
'Author' => Member::class,
'Publisher' => Member::class,
'Byte' => ContentByte::class,
];
}
@NightJar
Copy link
Author

c.f. silverstripe/silverstripe-versioned-snapshots#41

The folder structure was as follows (as hinted by namespaces):

app/src/Migrations
app/tests/Migrations
app/tests/Migraions/Stubs

Where composer.json:

    "autoload": {
        "psr-4": {
            "App\\": "app/src/",
            "App\\Tests\\": "app/tests/"
        }
    },

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment