Completed
Push — hash-nonce ( 07e2e8 )
by Sam
08:52
created

Versioned   F

Complexity

Total Complexity 218

Size/Duplication

Total Lines 1558
Duplicated Lines 2.89 %

Coupling/Cohesion

Components 2
Dependencies 22

Importance

Changes 1
Bugs 1 Features 1
Metric Value
dl 45
loc 1558
rs 0.6314
c 1
b 1
f 1
wmc 218
lcom 2
cbo 22

57 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 11 2
B augmentDataQueryCreation() 0 14 5
D augmentSQL() 6 139 29
A augmentLoadLazyFields() 0 19 4
A on_db_reset() 0 11 3
D augmentDatabase() 0 162 26
C augmentWriteVersioned() 0 53 10
F augmentWrite() 0 70 18
A writeWithoutVersion() 0 5 1
A onAfterSkippedWrite() 0 3 1
A canView() 0 6 2
A hasVersionField() 0 11 3
A extendWithSuffix() 0 16 4
A latestPublished() 0 13 2
B publish() 0 54 6
A stagesDiffer() 0 20 2
D allVersions() 3 43 10
A baseTable() 0 10 3
C choose_site_stage() 0 41 15
A get_one_by_stage() 0 6 1
C get_versionnumber_by_stage() 0 27 7
C prepopulate_versionnumber_cache() 0 27 7
A deleteFromStage() 0 13 1
A writeToStage() 10 10 1
A get_latest_version() 8 8 1
A isLatestVersion() 0 8 2
A get_including_deleted() 0 8 1
A get_version() 9 9 1
A get_all_versions() 0 8 1
A cacheKeyComponent() 0 3 1
A reset() 0 5 1
B uniqueToIndex() 0 22 6
A augmentWriteStaged() 0 13 2
A onAfterWrite() 0 3 1
C canViewVersioned() 0 37 7
A canViewStage() 9 9 2
A canBeVersioned() 0 5 3
A migrateVersion() 0 3 1
A Versions() 0 3 1
A compareVersions() 0 8 1
B can_choose_site_stage() 0 13 5
A set_reading_mode() 0 3 1
A get_reading_mode() 0 3 1
A get_live_stage() 0 3 1
A current_stage() 0 7 2
A current_archived_date() 0 4 2
A reading_stage() 0 3 1
A reading_archived_date() 0 3 1
A get_by_stage() 0 9 1
A doRollbackTo() 0 8 1
A updateFieldLabels() 0 3 1
A updateCMSFields() 0 5 1
A onBeforeDuplicate() 0 3 1
A flushCache() 0 3 1
A getVersionedStages() 0 3 1
A getDefaultStage() 0 3 1
A get_template_global_variables() 0 5 1

How to fix   Duplicated Code    Complexity   

Duplicated Code

Duplicate code is one of the most pungent code smells. A rule that is often used is to re-structure code once it is duplicated in three or more places.

Common duplication problems, and corresponding solutions are:

Complex Class

 Tip:   Before tackling complexity, make sure that you eliminate any duplication first. This often can reduce the size of classes significantly.

Complex classes like Versioned often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Versioned, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
/**
4
 * The Versioned extension allows your DataObjects to have several versions, allowing you to rollback changes and view
5
 * history. An example of this is the pages used in the CMS.
6
 *
7
 * @property int $Version
8
 *
9
 * @package framework
10
 * @subpackage model
11
 *
12
 * @property DataObject owner
13
 *
14
 * @property int  RecordID
15
 * @property int  Version
16
 * @property bool WasPublished
17
 * @property int  AuthorID
18
 * @property int  PublisherID
19
 */
20
class Versioned extends DataExtension implements TemplateGlobalProvider {
21
22
	/**
23
	 * An array of possible stages.
24
	 * @var array
25
	 */
26
	protected $stages;
27
28
	/**
29
	 * The 'default' stage.
30
	 * @var string
31
	 */
32
	protected $defaultStage;
33
34
	/**
35
	 * The 'live' stage.
36
	 * @var string
37
	 */
38
	protected $liveStage;
39
40
	/**
41
	 * The default reading mode
42
	 */
43
	const DEFAULT_MODE = 'Stage.Live';
44
45
	/**
46
	 * A version that a DataObject should be when it is 'migrating', that is, when it is in the process of moving from
47
	 * one stage to another.
48
	 * @var string
49
	 */
50
	public $migratingVersion;
51
52
	/**
53
	 * A cache used by get_versionnumber_by_stage(). Clear through {@link flushCache()}.
54
	 * @var array
55
	 */
56
	protected static $cache_versionnumber;
57
58
	/** @var string */
59
	protected static $reading_mode = null;
60
61
	/**
62
	 * Flag which is temporarily changed during the write() process to influence augmentWrite() behaviour. If set to
63
	 * true, no new version will be created for the following write. Needs to be public as other classes introspect this
64
	 * state during the write process in order to adapt to this versioning behaviour.
65
	 * @var bool
66
	 */
67
	public $_nextWriteWithoutVersion = false;
68
69
	/**
70
	 * Additional database columns for the new "_versions" table. Used in {@link augmentDatabase()} and all Versioned
71
	 * calls extending or creating SELECT statements.
72
	 * @var array
73
	 */
74
	private static $db_for_versions_table = array(
75
		"RecordID" => "Int",
76
		"Version" => "Int",
77
		"WasPublished" => "Boolean",
78
		"AuthorID" => "Int",
79
		"PublisherID" => "Int"
80
	);
81
82
	private static $db = array(
83
		'Version' => 'Int'
84
	);
85
86
	/**
87
	 * Used to enable or disable the prepopulation of the version number cache. Defaults to true.
88
	 * @var bool
89
   	 */
90
	private static $prepopulate_versionnumber_cache = true;
91
92
	/**
93
	 * Keep track of the archive tables that have been created.
94
	 * @var array
95
	 */
96
	private static $archive_tables = array();
97
98
	/**
99
	 * Additional database indexes for the new "_versions" table. Used in {@link augmentDatabase()}.
100
	 * @var array
101
	 */
102
	private static $indexes_for_versions_table = array(
103
		'RecordID_Version' => '("RecordID","Version")',
104
		'RecordID' => true,
105
		'Version' => true,
106
		'AuthorID' => true,
107
		'PublisherID' => true,
108
	);
109
110
111
	/**
112
	 * An array of DataObject extensions that may require versioning for extra tables. The array value is a set of
113
	 * suffixes to form these table names, assuming a preceding '_'. E.g. if Extension1 creates a new table
114
	 * 'Class_suffix1' and Extension2 the tables 'Class_suffix2' and 'Class_suffix3':
115
	 *
116
	 * 	$versionableExtensions = array(
117
	 * 		'Extension1' => 'suffix1',
118
	 * 		'Extension2' => array('suffix2', 'suffix3'),
119
	 * 	);
120
	 *
121
	 * This can also be manipulated by updating the current loaded config
122
	 *
123
	 * SiteTree:
124
	 *   versionableExtensions:
125
	 *     - Extension1:
126
	 *       - suffix1
127
	 *       - suffix2
128
	 *     - Extension2:
129
	 *       - suffix1
130
	 *       - suffix2
131
	 *
132
	 * or programatically:
133
	 *
134
	 *  Config::inst()->update($this->owner->class, 'versionableExtensions',
135
	 *  array('Extension1' => 'suffix1', 'Extension2' => array('suffix2', 'suffix3')));
136
	 *
137
	 * Make sure your extension has a static $enabled-property that determines if it is processed by Versioned.
138
	 *
139
	 * @var array
140
	 */
141
	protected static $versionableExtensions = array('Translatable' => 'lang');
142
143
	/**
144
	 * Permissions necessary to view records outside of the live stage (e.g. archive / draft stage).
145
	 *
146
	 * @config
147
	 * @var array
148
	 */
149
	private static $non_live_permissions = array('CMS_ACCESS_LeftAndMain', 'CMS_ACCESS_CMSMain', 'VIEW_DRAFT_CONTENT');
150
151
	/**
152
	 * Reset static configuration variables to their default values.
153
	 */
154
	public static function reset() {
155
		self::$reading_mode = '';
156
157
		Session::clear('readingMode');
158
	}
159
160
	/**
161
	 * Construct a new Versioned object.
162
	 *
163
	 * @param array $stages The different stages the versioned object can be. The first stage is considered the
164
	 *                      'default' stage, the last stage is considered the 'live' stage.
165
	 */
166
	public function __construct($stages = array('Stage','Live')) {
167
		parent::__construct();
168
169
		if(!is_array($stages)) {
170
			$stages = func_get_args();
171
		}
172
173
		$this->stages = $stages;
174
		$this->defaultStage = reset($stages);
175
		$this->liveStage = array_pop($stages);
176
	}
177
178
	/**
179
	 * Amend freshly created DataQuery objects with versioned-specific information.
180
	 *
181
	 * @param SQLQuery  $query
182
	 * @param DataQuery $dataQuery
183
	 */
184
	public function augmentDataQueryCreation(SQLQuery &$query, DataQuery &$dataQuery) {
0 ignored issues
show
Unused Code introduced by
The parameter $query is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
185
		$parts = explode('.', Versioned::get_reading_mode());
186
187
		if($parts[0] == 'Archive') {
188
			$dataQuery->setQueryParam('Versioned.mode', 'archive');
189
			$dataQuery->setQueryParam('Versioned.date', $parts[1]);
190
191
		} else if($parts[0] == 'Stage' && $parts[1] != $this->defaultStage
192
				&& array_search($parts[1],$this->stages) !== false) {
193
194
			$dataQuery->setQueryParam('Versioned.mode', 'stage');
195
			$dataQuery->setQueryParam('Versioned.stage', $parts[1]);
196
		}
197
	}
198
199
	/**
200
	 * Augment the the SQLQuery that is created by the DataQuery.
201
	 * @todo Should this all go into VersionedDataQuery?
202
	 *
203
	 * @param SQLQuery  $query
204
	 * @param DataQuery $dataQuery
205
	 */
206
	public function augmentSQL(SQLQuery &$query, DataQuery &$dataQuery = null) {
207
		if(!$dataQuery || !$dataQuery->getQueryParam('Versioned.mode')) {
208
			return;
209
		}
210
211
		$baseTable = ClassInfo::baseDataClass($dataQuery->dataClass());
212
213
		switch($dataQuery->getQueryParam('Versioned.mode')) {
214
		// Reading a specific data from the archive
215
		case 'archive':
216
			$date = $dataQuery->getQueryParam('Versioned.date');
217
			foreach($query->getFrom() as $table => $dummy) {
218
				if(!DB::get_schema()->hasTable($table . '_versions')) {
219
					continue;
220
				}
221
222
				$query->renameTable($table, $table . '_versions');
223
				$query->replaceText("\"{$table}_versions\".\"ID\"", "\"{$table}_versions\".\"RecordID\"");
224
				$query->replaceText("`{$table}_versions`.`ID`", "`{$table}_versions`.`RecordID`");
225
226
				// Add all <basetable>_versions columns
227 View Code Duplication
				foreach(Config::inst()->get('Versioned', 'db_for_versions_table') as $name => $type) {
0 ignored issues
show
Bug introduced by
The expression \Config::inst()->get('Ve...db_for_versions_table') of type array|integer|double|string|boolean is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
228
					$query->selectField(sprintf('"%s_versions"."%s"', $baseTable, $name), $name);
229
				}
230
				$query->selectField(sprintf('"%s_versions"."%s"', $baseTable, 'RecordID'), "ID");
231
232
				if($table != $baseTable) {
233
					$query->addWhere("\"{$table}_versions\".\"Version\" = \"{$baseTable}_versions\".\"Version\"");
234
				}
235
			}
236
			// Link to the version archived on that date
237
			$query->addWhere(array(
238
				"\"{$baseTable}_versions\".\"Version\" IN
239
				(SELECT LatestVersion FROM
240
					(SELECT
241
						\"{$baseTable}_versions\".\"RecordID\",
242
						MAX(\"{$baseTable}_versions\".\"Version\") AS LatestVersion
243
						FROM \"{$baseTable}_versions\"
244
						WHERE \"{$baseTable}_versions\".\"LastEdited\" <= ?
245
						GROUP BY \"{$baseTable}_versions\".\"RecordID\"
246
					) AS \"{$baseTable}_versions_latest\"
247
					WHERE \"{$baseTable}_versions_latest\".\"RecordID\" = \"{$baseTable}_versions\".\"RecordID\"
248
				)" => $date
249
			));
250
			break;
251
252
		// Reading a specific stage (Stage or Live)
253
		case 'stage':
254
			$stage = $dataQuery->getQueryParam('Versioned.stage');
255
			if($stage && ($stage != $this->defaultStage)) {
256
				foreach($query->getFrom() as $table => $dummy) {
257
					// Only rewrite table names that are actually part of the subclass tree
258
					// This helps prevent rewriting of other tables that get joined in, in
259
					// particular, many_many tables
260
					if(class_exists($table) && ($table == $this->owner->class
261
							|| is_subclass_of($table, $this->owner->class)
262
							|| is_subclass_of($this->owner->class, $table))) {
263
						$query->renameTable($table, $table . '_' . $stage);
264
					}
265
				}
266
			}
267
			break;
268
269
		// Reading a specific stage, but only return items that aren't in any other stage
270
		case 'stage_unique':
271
			$stage = $dataQuery->getQueryParam('Versioned.stage');
272
273
			// Recurse to do the default stage behavior (must be first, we rely on stage renaming happening before
274
			// below)
275
			$dataQuery->setQueryParam('Versioned.mode', 'stage');
276
			$this->augmentSQL($query, $dataQuery);
277
			$dataQuery->setQueryParam('Versioned.mode', 'stage_unique');
0 ignored issues
show
Bug introduced by
It seems like $dataQuery is not always an object, but can also be of type null. Maybe add an additional type check?

If a variable is not always an object, we recommend to add an additional type check to ensure your method call is safe:

function someFunction(A $objectMaybe = null)
{
    if ($objectMaybe instanceof A) {
        $objectMaybe->doSomething();
    }
}
Loading history...
278
279
			// Now exclude any ID from any other stage. Note that we double rename to avoid the regular stage rename
280
			// renaming all subquery references to be Versioned.stage
281
			foreach($this->stages as $excluding) {
282
				if ($excluding == $stage) continue;
283
284
				$tempName = 'ExclusionarySource_'.$excluding;
285
				$excludingTable = $baseTable . ($excluding && $excluding != $this->defaultStage ? "_$excluding" : '');
286
287
				$query->addWhere('"'.$baseTable.'"."ID" NOT IN (SELECT "ID" FROM "'.$tempName.'")');
288
				$query->renameTable($tempName, $excludingTable);
289
			}
290
			break;
291
292
		// Return all version instances
293
		case 'all_versions':
294
		case 'latest_versions':
295
			foreach($query->getFrom() as $alias => $join) {
296
				if($alias != $baseTable) {
297
					$query->setJoinFilter($alias, "\"$alias\".\"RecordID\" = \"{$baseTable}_versions\".\"RecordID\""
298
						. " AND \"$alias\".\"Version\" = \"{$baseTable}_versions\".\"Version\"");
299
				}
300
				$query->renameTable($alias, $alias . '_versions');
301
			}
302
303
			// Add all <basetable>_versions columns
304 View Code Duplication
			foreach(Config::inst()->get('Versioned', 'db_for_versions_table') as $name => $type) {
0 ignored issues
show
Bug introduced by
The expression \Config::inst()->get('Ve...db_for_versions_table') of type array|integer|double|string|boolean is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
305
				$query->selectField(sprintf('"%s_versions"."%s"', $baseTable, $name), $name);
306
			}
307
308
			// Alias the record ID as the row ID
309
			$query->selectField(sprintf('"%s_versions"."%s"', $baseTable, 'RecordID'), "ID");
310
311
			// Ensure that any sort order referring to this ID is correctly aliased
312
			$orders = $query->getOrderBy();
313
			foreach($orders as $order => $dir) {
314
				if($order === "\"$baseTable\".\"ID\"") {
315
					unset($orders[$order]);
316
					$orders["\"{$baseTable}_versions\".\"RecordID\""] = $dir;
317
				}
318
			}
319
			$query->setOrderBy($orders);
320
321
			// latest_version has one more step. Return latest version instances, regardless of whether they are on a
322
			// particular stage. This provides "show all, including deleted" functonality
323
			if($dataQuery->getQueryParam('Versioned.mode') == 'latest_versions') {
324
				$query->addWhere(
325
					"\"{$alias}_versions\".\"Version\" IN
0 ignored issues
show
Bug introduced by
The variable $alias seems to be defined by a foreach iteration on line 295. Are you sure the iterator is never empty, otherwise this variable is not defined?

It seems like you are relying on a variable being defined by an iteration:

foreach ($a as $b) {
}

// $b is defined here only if $a has elements, for example if $a is array()
// then $b would not be defined here. To avoid that, we recommend to set a
// default value for $b.


// Better
$b = 0; // or whatever default makes sense in your context
foreach ($a as $b) {
}

// $b is now guaranteed to be defined here.
Loading history...
326
					(SELECT LatestVersion FROM
327
						(SELECT
328
							\"{$alias}_versions\".\"RecordID\",
329
							MAX(\"{$alias}_versions\".\"Version\") AS LatestVersion
330
							FROM \"{$alias}_versions\"
331
							GROUP BY \"{$alias}_versions\".\"RecordID\"
332
						) AS \"{$alias}_versions_latest\"
333
						WHERE \"{$alias}_versions_latest\".\"RecordID\" = \"{$alias}_versions\".\"RecordID\"
334
					)");
335
			} else {
336
				// If all versions are requested, ensure that records are sorted by this field
337
				$query->addOrderBy(sprintf('"%s_versions"."%s"', $baseTable, 'Version'));
338
			}
339
			break;
340
		default:
341
			throw new InvalidArgumentException("Bad value for query parameter Versioned.mode: "
342
				. $dataQuery->getQueryParam('Versioned.mode'));
343
		}
344
	}
345
346
	/**
347
	 * For lazy loaded fields requiring extra SQL manipulation, ie versioning.
348
	 *
349
	 * @param SQLQuery   $query
350
	 * @param DataQuery  $dataQuery
351
	 * @param DataObject $dataObject
352
	 */
353
	public function augmentLoadLazyFields(SQLQuery &$query, DataQuery &$dataQuery = null, $dataObject) {
0 ignored issues
show
Unused Code introduced by
The parameter $query is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
354
		// The VersionedMode local variable ensures that this decorator only applies to queries that have originated
355
		// from the Versioned object, and have the Versioned metadata set on the query object. This prevents regular
356
		// queries from accidentally querying the *_versions tables.
357
		$versionedMode = $dataObject->getSourceQueryParam('Versioned.mode');
358
		$dataClass = $dataQuery->dataClass();
0 ignored issues
show
Bug introduced by
It seems like $dataQuery is not always an object, but can also be of type null. Maybe add an additional type check?

If a variable is not always an object, we recommend to add an additional type check to ensure your method call is safe:

function someFunction(A $objectMaybe = null)
{
    if ($objectMaybe instanceof A) {
        $objectMaybe->doSomething();
    }
}
Loading history...
359
		$modesToAllowVersioning = array('all_versions', 'latest_versions', 'archive');
360
		if(
361
			!empty($dataObject->Version) &&
362
			(!empty($versionedMode) && in_array($versionedMode,$modesToAllowVersioning))
363
		) {
364
			$dataQuery->where("\"$dataClass\".\"RecordID\" = " . $dataObject->ID);
365
			$dataQuery->where("\"$dataClass\".\"Version\" = " . $dataObject->Version);
366
			$dataQuery->setQueryParam('Versioned.mode', 'all_versions');
367
		} else {
368
			// Same behaviour as in DataObject->loadLazyFields
369
			$dataQuery->where("\"$dataClass\".\"ID\" = {$dataObject->ID}")->limit(1);
370
		}
371
	}
372
373
	/**
374
	 * Called by {@link SapphireTest} when the database is reset.
375
	 *
376
	 * @todo Reduce the coupling between this and SapphireTest, somehow.
377
	 */
378
	public static function on_db_reset() {
379
		// Drop all temporary tables
380
		$db = DB::get_conn();
381
		foreach(self::$archive_tables as $tableName) {
382
			if(method_exists($db, 'dropTable')) $db->dropTable($tableName);
383
			else $db->query("DROP TABLE \"$tableName\"");
384
		}
385
386
		// Remove references to them
387
		self::$archive_tables = array();
388
	}
389
390
	public function augmentDatabase() {
391
		$classTable = $this->owner->class;
392
393
		$isRootClass = ($this->owner->class == ClassInfo::baseDataClass($this->owner->class));
394
395
		// Build a list of suffixes whose tables need versioning
396
		$allSuffixes = array();
397
		$versionableExtensions = $this->owner->config()->versionableExtensions;
0 ignored issues
show
Documentation introduced by
The property versionableExtensions does not exist on object<Config_ForClass>. Since you implemented __set, maybe consider adding a @property annotation.

Since your code implements the magic setter _set, this function will be called for any write access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

Since the property has write access only, you can use the @property-write annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
398
		if(count($versionableExtensions)){
399
400
			foreach ($versionableExtensions as $versionableExtension => $suffixes) {
401
				if ($this->owner->hasExtension($versionableExtension)) {
402
					$allSuffixes = array_merge($allSuffixes, (array)$suffixes);
403
					foreach ((array)$suffixes as $suffix) {
404
						$allSuffixes[$suffix] = $versionableExtension;
405
					}
406
				}
407
			}
408
409
		}
410
411
		// Add the default table with an empty suffix to the list (table name = class name)
412
		array_push($allSuffixes,'');
413
414
		foreach ($allSuffixes as $key => $suffix) {
415
			// check that this is a valid suffix
416
			if (!is_int($key)) continue;
417
418
			if ($suffix) $table = "{$classTable}_$suffix";
419
			else $table = $classTable;
420
421
			if($fields = DataObject::database_fields($this->owner->class)) {
422
				$options = Config::inst()->get($this->owner->class, 'create_table_options', Config::FIRST_SET);
423
				$indexes = $this->owner->databaseIndexes();
424
				if ($suffix && ($ext = $this->owner->getExtensionInstance($allSuffixes[$suffix]))) {
425
					if (!$ext->isVersionedTable($table)) continue;
426
					$ext->setOwner($this->owner);
427
					$fields = $ext->fieldsInExtraTables($suffix);
428
					$ext->clearOwner();
429
					$indexes = $fields['indexes'];
430
					$fields = $fields['db'];
431
				}
432
433
				// Create tables for other stages
434
				foreach($this->stages as $stage) {
435
					// Extra tables for _Live, etc.
436
					// Change unique indexes to 'index'.  Versioned tables may run into unique indexing difficulties
437
					// otherwise.
438
					$indexes = $this->uniqueToIndex($indexes);
439
					if($stage != $this->defaultStage) {
440
						DB::require_table("{$table}_$stage", $fields, $indexes, false, $options);
0 ignored issues
show
Documentation introduced by
$indexes is of type array, but the function expects a string|null.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
441
					}
442
443
					// Version fields on each root table (including Stage)
444
					/*
445
					if($isRootClass) {
446
						$stageTable = ($stage == $this->defaultStage) ? $table : "{$table}_$stage";
447
						$parts=Array('datatype'=>'int', 'precision'=>11, 'null'=>'not null', 'default'=>(int)0);
448
						$values=Array('type'=>'int', 'parts'=>$parts);
449
						DB::requireField($stageTable, 'Version', $values);
450
					}
451
					*/
452
				}
453
454
				if($isRootClass) {
455
					// Create table for all versions
456
					$versionFields = array_merge(
457
						Config::inst()->get('Versioned', 'db_for_versions_table'),
458
						(array)$fields
459
					);
460
461
					$versionIndexes = array_merge(
462
						Config::inst()->get('Versioned', 'indexes_for_versions_table'),
463
						(array)$indexes
464
					);
465
				} else {
466
					// Create fields for any tables of subclasses
467
					$versionFields = array_merge(
468
						array(
469
							"RecordID" => "Int",
470
							"Version" => "Int",
471
						),
472
						(array)$fields
473
					);
474
475
					//Unique indexes will not work on versioned tables, so we'll convert them to standard indexes:
476
					$indexes = $this->uniqueToIndex($indexes);
477
					$versionIndexes = array_merge(
478
						array(
479
							'RecordID_Version' => array('type' => 'unique', 'value' => '"RecordID","Version"'),
480
							'RecordID' => true,
481
							'Version' => true,
482
						),
483
						(array)$indexes
484
					);
485
				}
486
487
				if(DB::get_schema()->hasTable("{$table}_versions")) {
488
					// Fix data that lacks the uniqueness constraint (since this was added later and bugs meant that
489
					// the constraint was validated)
490
					$duplications = DB::query("SELECT MIN(\"ID\") AS \"ID\", \"RecordID\", \"Version\"
491
						FROM \"{$table}_versions\" GROUP BY \"RecordID\", \"Version\"
492
						HAVING COUNT(*) > 1");
493
494
					foreach($duplications as $dup) {
495
						DB::alteration_message("Removing {$table}_versions duplicate data for "
496
							."{$dup['RecordID']}/{$dup['Version']}" ,"deleted");
497
						DB::prepared_query(
498
							"DELETE FROM \"{$table}_versions\" WHERE \"RecordID\" = ?
499
							AND \"Version\" = ? AND \"ID\" != ?",
500
							array($dup['RecordID'], $dup['Version'], $dup['ID'])
501
						);
502
					}
503
504
					// Remove junk which has no data in parent classes. Only needs to run the following when versioned
505
					// data is spread over multiple tables
506
					if(!$isRootClass && ($versionedTables = ClassInfo::dataClassesFor($table))) {
507
508
						foreach($versionedTables as $child) {
509
							if($table === $child) break; // only need subclasses
510
511
							// Select all orphaned version records
512
							$orphanedQuery = SQLSelect::create()
513
								->selectField("\"{$table}_versions\".\"ID\"")
514
								->setFrom("\"{$table}_versions\"");
515
516
							// If we have a parent table limit orphaned records
517
							// to only those that exist in this
518
							if(DB::get_schema()->hasTable("{$child}_versions")) {
519
								$orphanedQuery
520
									->addLeftJoin(
521
										"{$child}_versions",
522
										"\"{$child}_versions\".\"RecordID\" = \"{$table}_versions\".\"RecordID\"
523
										AND \"{$child}_versions\".\"Version\" = \"{$table}_versions\".\"Version\""
524
									)
525
									->addWhere("\"{$child}_versions\".\"ID\" IS NULL");
526
							}
527
528
							$count = $orphanedQuery->count();
529
							if($count > 0) {
530
								DB::alteration_message("Removing {$count} orphaned versioned records", "deleted");
531
								$ids = $orphanedQuery->execute()->column();
532
								foreach($ids as $id) {
533
									DB::prepared_query(
534
										"DELETE FROM \"{$table}_versions\" WHERE \"ID\" = ?",
535
										array($id)
536
									);
537
								}
538
							}
539
						}
540
					}
541
				}
542
543
				DB::require_table("{$table}_versions", $versionFields, $versionIndexes, true, $options);
0 ignored issues
show
Documentation introduced by
$versionFields is of type array, but the function expects a string|null.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
Documentation introduced by
$versionIndexes is of type array, but the function expects a string|null.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
544
			} else {
545
				DB::dont_require_table("{$table}_versions");
546
				foreach($this->stages as $stage) {
547
					if($stage != $this->defaultStage) DB::dont_require_table("{$table}_$stage");
548
				}
549
			}
550
		}
551
	}
552
553
	/**
554
	 * Helper for augmentDatabase() to find unique indexes and convert them to non-unique
555
	 *
556
	 * @param array $indexes The indexes to convert
557
	 * @return array $indexes
558
	 */
559
	private function uniqueToIndex($indexes) {
560
		$unique_regex = '/unique/i';
561
		$results = array();
562
		foreach ($indexes as $key => $index) {
563
			$results[$key] = $index;
564
565
			// support string descriptors
566
			if (is_string($index)) {
567
				if (preg_match($unique_regex, $index)) {
568
					$results[$key] = preg_replace($unique_regex, 'index', $index);
569
				}
570
			}
571
572
			// canonical, array-based descriptors
573
			elseif (is_array($index)) {
574
				if (strtolower($index['type']) == 'unique') {
575
					$results[$key]['type'] = 'index';
576
				}
577
			}
578
		}
579
		return $results;
580
	}
581
582
	/**
583
	 * Generates a ($table)_version DB manipulation and injects it into the current $manipulation
584
	 *
585
	 * @param SQLQuery $manipulation The query to augment
586
	 */
587
	protected function augmentWriteVersioned(&$manipulation, $table, $recordID) {
588
		$baseDataClass = ClassInfo::baseDataClass($table);
589
590
		// Set up a new entry in (table)_versions
591
		$newManipulation = array(
592
			"command" => "insert",
593
			"fields" => isset($manipulation[$table]['fields']) ? $manipulation[$table]['fields'] : null
594
		);
595
596
		// Add any extra, unchanged fields to the version record.
597
		$data = DB::prepared_query("SELECT * FROM \"$table\" WHERE \"ID\" = ?", array($recordID))->record();
598
599
		if ($data) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $data of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
600
			$fields = DataObject::database_fields($table);
601
602
			if (is_array($fields)) {
603
				$data = array_intersect_key($data, $fields);
604
605
				foreach ($data as $k => $v) {
606
					if (!isset($newManipulation['fields'][$k])) {
607
						$newManipulation['fields'][$k] = $v;
608
					}
609
				}
610
			}
611
		}
612
613
		// Ensure that the ID is instead written to the RecordID field
614
		$newManipulation['fields']['RecordID'] = $recordID;
615
		unset($newManipulation['fields']['ID']);
616
617
		// Generate next version ID to use
618
		$nextVersion = 0;
619
		if($recordID) {
620
			$nextVersion = DB::prepared_query("SELECT MAX(\"Version\") + 1
621
				FROM \"{$baseDataClass}_versions\" WHERE \"RecordID\" = ?",
622
				array($recordID)
623
			)->value();
624
		}
625
		$nextVersion = $nextVersion ?: 1;
626
627
		if($table === $baseDataClass) {
628
			// Write AuthorID for baseclass
629
			$userID = (Member::currentUser()) ? Member::currentUser()->ID : 0;
630
			$newManipulation['fields']['AuthorID'] = $userID;
631
632
			// Update main table version if not previously known
633
			$manipulation[$table]['fields']['Version'] = $nextVersion;
634
		}
635
636
		// Update _versions table manipulation
637
		$newManipulation['fields']['Version'] = $nextVersion;
638
		$manipulation["{$table}_versions"] = $newManipulation;
639
	}
640
641
	/**
642
	 * Rewrite the given manipulation to update the selected (non-default) stage
643
	 *
644
	 * @param array $manipulation Source manipulation data
645
	 * @param string $table Name of table
646
	 * @param int $recordID ID of record to version
647
	 */
648
	protected function augmentWriteStaged(&$manipulation, $table, $recordID) {
649
		// If the record has already been inserted in the (table), get rid of it.
650
		if($manipulation[$table]['command'] == 'insert') {
651
			DB::prepared_query(
652
				"DELETE FROM \"{$table}\" WHERE \"ID\" = ?",
653
				array($recordID)
654
			);
655
		}
656
657
		$newTable = $table . '_' . Versioned::current_stage();
658
		$manipulation[$newTable] = $manipulation[$table];
659
		unset($manipulation[$table]);
660
	}
661
662
663
	public function augmentWrite(&$manipulation) {
664
		// get Version number from base data table on write
665
		$version = null;
666
		$baseDataClass = ClassInfo::baseDataClass($this->owner->class);
667
		if(isset($manipulation[$baseDataClass]['fields'])) {
668
			if ($this->migratingVersion) {
669
				$manipulation[$baseDataClass]['fields']['Version'] = $this->migratingVersion;
670
			}
671
			if (isset($manipulation[$baseDataClass]['fields']['Version'])) {
672
				$version = $manipulation[$baseDataClass]['fields']['Version'];
673
			}
674
		}
675
676
		// Update all tables
677
		$tables = array_keys($manipulation);
678
		foreach($tables as $table) {
679
680
			// Make sure that the augmented write is being applied to a table that can be versioned
681
			if( !$this->canBeVersioned($table) ) {
682
				unset($manipulation[$table]);
683
				continue;
684
			}
685
686
			// Get ID field
687
			$id = $manipulation[$table]['id']
688
				? $manipulation[$table]['id']
689
				: $manipulation[$table]['fields']['ID'];
690
			if(!$id) {
691
				user_error("Couldn't find ID in " . var_export($manipulation[$table], true), E_USER_ERROR);
692
			}
693
694
			if($version < 0 || $this->_nextWriteWithoutVersion) {
695
				// Putting a Version of -1 is a signal to leave the version table alone, despite their being no version
696
				unset($manipulation[$table]['fields']['Version']);
697
			} elseif(empty($version)) {
698
				// If we haven't got a version #, then we're creating a new version.
699
				// Otherwise, we're just copying a version to another table
700
				$this->augmentWriteVersioned($manipulation, $table, $id);
701
			}
702
703
			// Remove "Version" column from subclasses of baseDataClass
704
			if(!$this->hasVersionField($table)) {
705
				unset($manipulation[$table]['fields']['Version']);
706
			}
707
708
			// Grab a version number - it should be the same across all tables.
709
			if(isset($manipulation[$table]['fields']['Version'])) {
710
				$thisVersion = $manipulation[$table]['fields']['Version'];
711
			}
712
713
			// If we're editing Live, then use (table)_Live instead of (table)
714
			if(
715
				Versioned::current_stage()
716
				&& Versioned::current_stage() != $this->defaultStage
717
				&& in_array(Versioned::current_stage(), $this->stages)
718
			) {
719
				$this->augmentWriteStaged($manipulation, $table, $id);
720
			}
721
		}
722
723
		// Clear the migration flag
724
		if($this->migratingVersion) {
725
			$this->migrateVersion(null);
726
		}
727
728
		// Add the new version # back into the data object, for accessing after this write
729
		if(isset($thisVersion)) {
730
			$this->owner->Version = str_replace("'","", $thisVersion);
0 ignored issues
show
Documentation introduced by
The property Version does not exist on object<DataObject>. Since you implemented __set, maybe consider adding a @property annotation.

Since your code implements the magic setter _set, this function will be called for any write access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

Since the property has write access only, you can use the @property-write annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
731
		}
732
	}
733
734
	/**
735
	 * Perform a write without affecting the version table.
736
	 *
737
	 * @return int The ID of the written record
738
	 */
739
	public function writeWithoutVersion() {
740
		$this->_nextWriteWithoutVersion = true;
741
742
		return $this->owner->write();
743
	}
744
745
	public function onAfterWrite() {
746
		$this->_nextWriteWithoutVersion = false;
747
	}
748
749
	/**
750
	 * If a write was skipped, then we need to ensure that we don't leave a migrateVersion() value lying around for the
751
	 * next write.
752
	 */
753
	public function onAfterSkippedWrite() {
754
		$this->migrateVersion(null);
755
	}
756
757
	/**
758
	 * Extend permissions to include additional security for objects that are not published to live.
759
	 *
760
	 * @param Member $member
761
	 * @return bool|null
762
	 */
763
	public function canView($member = null) {
764
		// Invoke default version-gnostic canView
765
		if ($this->owner->canViewVersioned($member) === false) {
766
			return false;
767
		}
768
	}
769
770
	/**
771
	 * Determine if there are any additional restrictions on this object for the given reading version.
772
	 *
773
	 * Override this in a subclass to customise any additional effect that Versioned applies to canView.
774
	 *
775
	 * This is expected to be called by canView, and thus is only responsible for denying access if
776
	 * the default canView would otherwise ALLOW access. Thus it should not be called in isolation
777
	 * as an authoritative permission check.
778
	 *
779
	 * This has the following extension points:
780
	 *  - canViewDraft is invoked if Mode = stage and Stage = stage
781
	 *  - canViewArchived is invoked if Mode = archive
782
	 *
783
	 * @param Member $member
784
	 * @return bool False is returned if the current viewing mode denies visibility
785
	 */
786
	public function canViewVersioned($member = null) {
787
		// Bypass when live stage
788
		$mode = $this->owner->getSourceQueryParam("Versioned.mode");
789
		$stage = $this->owner->getSourceQueryParam("Versioned.stage");
790
		if ($mode === 'stage' && $stage === static::get_live_stage()) {
791
			return true;
792
		}
793
794
		// Bypass if site is unsecured
795
		if (Session::get('unsecuredDraftSite')) {
796
			return true;
797
		}
798
799
		// If there are less than 2 stages, we can exit early since comparing stages is not needed
800
		if(count($this->stages) < 2){
801
			return true;
802
		}
803
804
		// If we weren't definitely loaded from live, and we can't view non-live content, we need to
805
		// check to make sure this version is the live version and so can be viewed.
806
		$latestVersion = Versioned::get_versionnumber_by_stage($this->owner->class, $this->liveStage, $this->owner->ID);
807
		if ($latestVersion == $this->owner->Version) {
808
			// Even if this is loaded from a non-live stage, this is the live version
809
			return true;
810
		}
811
812
		// Extend versioned behaviour
813
		$extended = $this->owner->extendedCan('canViewNonLive', $member);
0 ignored issues
show
Bug introduced by
It seems like $member defined by parameter $member on line 786 can be null; however, DataObject::extendedCan() does not accept null, maybe add an additional type check?

It seems like you allow that null is being passed for a parameter, however the function which is called does not seem to accept null.

We recommend to add an additional type check (or disallow null for the parameter):

function notNullable(stdClass $x) { }

// Unsafe
function withoutCheck(stdClass $x = null) {
    notNullable($x);
}

// Safe - Alternative 1: Adding Additional Type-Check
function withCheck(stdClass $x = null) {
    if ($x instanceof stdClass) {
        notNullable($x);
    }
}

// Safe - Alternative 2: Changing Parameter
function withNonNullableParam(stdClass $x) {
    notNullable($x);
}
Loading history...
814
		if($extended !== null) {
815
			return (bool)$extended;
816
		}
817
818
		// Fall back to default permission check
819
		$permissions = Config::inst()->get($this->owner->class, 'non_live_permissions', Config::FIRST_SET);
820
		$check = Permission::checkMember($member, $permissions);
821
		return (bool)$check;
822
	}
823
824
	/**
825
	 * Determines canView permissions for the latest version of this object on a specific stage.
826
	 * Usually the stage is read from {@link Versioned::current_stage()}.
827
	 *
828
	 * This method should be invoked by user code to check if a record is visible in the given stage.
829
	 *
830
	 * This method should not be called via ->extend('canViewStage'), but rather should be
831
	 * overridden in the extended class.
832
	 *
833
	 * @param string $stage
834
	 * @param Member $member
835
	 * @return bool
836
	 */
837 View Code Duplication
	public function canViewStage($stage = 'Live', $member = null) {
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
838
		$oldMode = Versioned::get_reading_mode();
839
		Versioned::reading_stage($stage);
840
841
		$versionFromStage = DataObject::get($this->owner->class)->byID($this->owner->ID);
842
843
		Versioned::set_reading_mode($oldMode);
844
		return $versionFromStage ? $versionFromStage->canView($member) : false;
845
	}
846
847
	/**
848
	 * Determine if a table supports the Versioned extensions (e.g. $table_versions does exists).
849
	 *
850
	 * @param string $table Table name
851
	 * @return bool
852
	 */
853
	public function canBeVersioned($table) {
854
		return ClassInfo::exists($table)
855
			&& is_subclass_of($table, 'DataObject')
856
			&& DataObject::has_own_table($table);
857
	}
858
859
	/**
860
	 * Check if a certain table has the 'Version' field.
861
	 *
862
	 * @param string $table Table name
863
	 * @return bool
864
	 */
865
	public function hasVersionField($table) {
866
		$rPos = strrpos($table,'_');
867
868
		if(($rPos !== false) && in_array(substr($table,$rPos), $this->stages)) {
869
			$tableWithoutStage = substr($table,0,$rPos);
870
		} else {
871
			$tableWithoutStage = $table;
872
		}
873
874
		return ('DataObject' == get_parent_class($tableWithoutStage));
875
	}
876
877
	/**
878
	 * @param string $table
879
	 * @return string
880
	 */
881
	public function extendWithSuffix($table) {
882
		$versionableExtensions = $this->owner->config()->versionableExtensions;
0 ignored issues
show
Documentation introduced by
The property versionableExtensions does not exist on object<Config_ForClass>. Since you implemented __set, maybe consider adding a @property annotation.

Since your code implements the magic setter _set, this function will be called for any write access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

Since the property has write access only, you can use the @property-write annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
883
884
		if(count($versionableExtensions)){
885
			foreach ($versionableExtensions as $versionableExtension => $suffixes) {
886
				if ($this->owner->hasExtension($versionableExtension)) {
887
					$ext = $this->owner->getExtensionInstance($versionableExtension);
888
					$ext->setOwner($this->owner);
889
					$table = $ext->extendWithSuffix($table);
890
					$ext->clearOwner();
891
				}
892
			}
893
		}
894
895
		return $table;
896
	}
897
898
	/**
899
	 * Get the latest published version of this object.
900
	 *
901
	 * @return DataObject
902
	 */
903
	public function latestPublished() {
904
		// Get the root data object class - this will have the version field
905
		$table1 = $this->owner->class;
906
		while( ($p = get_parent_class($table1)) != "DataObject") $table1 = $p;
907
908
		$table2 = $table1 . "_$this->liveStage";
909
910
		return DB::prepared_query("SELECT \"$table1\".\"Version\" = \"$table2\".\"Version\" FROM \"$table1\"
911
			 INNER JOIN \"$table2\" ON \"$table1\".\"ID\" = \"$table2\".\"ID\"
912
			 WHERE \"$table1\".\"ID\" = ?",
913
			array($this->owner->ID)
914
		)->value();
915
	}
916
917
	/**
918
	 * Move a database record from one stage to the other.
919
	 *
920
	 * @param string $fromStage        Place to copy from.  Can be either a stage name or a version number.
921
	 * @param string $toStage          Place to copy to.  Must be a stage name.
922
	 * @param bool   $createNewVersion Set this to true to create a new version number.  By default, the existing
923
	 *                                 version number will be copied over.
924
	 */
925
	public function publish($fromStage, $toStage, $createNewVersion = false) {
926
		$this->owner->extend('onBeforeVersionedPublish', $fromStage, $toStage, $createNewVersion);
927
928
		$baseClass = ClassInfo::baseDataClass($this->owner->class);
929
		$extTable = $this->extendWithSuffix($baseClass);
930
931
		/** @var Versioned|DataObject $from */
932
		if(is_numeric($fromStage)) {
933
			$from = Versioned::get_version($baseClass, $this->owner->ID, $fromStage);
934
		} else {
935
			$this->owner->flushCache();
936
			$from = Versioned::get_one_by_stage($baseClass, $fromStage, array(
0 ignored issues
show
Documentation introduced by
array("\"{$baseClass}\"....?" => $this->owner->ID) is of type array<string|integer,integer>, but the function expects a string.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
937
				"\"{$baseClass}\".\"ID\" = ?" => $this->owner->ID
938
			));
939
		}
940
		if(!$from) {
941
			user_error("Can't find {$this->owner->class}/{$this->owner->ID} in stage {$fromStage}", E_USER_WARNING);
942
			return;
943
		}
944
945
		// Set version of new record
946
		$from->forceChange();
947
		if($createNewVersion) {
948
			// Clear version to be automatically created on write
949
			$from->Version = null;
0 ignored issues
show
Documentation introduced by
The property Version does not exist on object<DataObject>. Since you implemented __set, maybe consider adding a @property annotation.

Since your code implements the magic setter _set, this function will be called for any write access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

Since the property has write access only, you can use the @property-write annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
950
		} else {
951
			$from->migrateVersion($from->Version);
952
953
			// Mark this version as having been published at some stage
954
			$publisherID = isset(Member::currentUser()->ID) ? Member::currentUser()->ID : 0;
955
			DB::prepared_query("UPDATE \"{$extTable}_versions\"
956
				SET \"WasPublished\" = ?, \"PublisherID\" = ?
957
				WHERE \"RecordID\" = ? AND \"Version\" = ?",
958
				array(1, $publisherID, $from->ID, $from->Version)
959
			);
960
		}
961
962
		// Change to new stage, write, and revert state
963
		$oldMode = Versioned::get_reading_mode();
964
		Versioned::reading_stage($toStage);
965
966
		$conn = DB::get_conn();
967
		if(method_exists($conn, 'allowPrimaryKeyEditing')) {
968
			$conn->allowPrimaryKeyEditing($baseClass, true);
969
			$from->write();
970
			$conn->allowPrimaryKeyEditing($baseClass, false);
971
		} else {
972
			$from->write();
973
		}
974
975
		$from->destroy();
976
977
		Versioned::set_reading_mode($oldMode);
978
	}
979
980
	/**
981
	 * Set the migrating version.
982
	 *
983
	 * @param string $version
984
	 */
985
	public function migrateVersion($version) {
986
		$this->migratingVersion = $version;
987
	}
988
989
	/**
990
	 * Compare two stages to see if they're different. Only checks the version numbers, not the actual content.
991
	 *
992
	 * @param string $stage1 The first stage to check
993
	 * @param string $stage2 The second stage to check
994
	 * @return bool
995
	 */
996
	public function stagesDiffer($stage1, $stage2) {
997
		$table1 = $this->baseTable($stage1);
998
		$table2 = $this->baseTable($stage2);
999
1000
		if(!is_numeric($this->owner->ID)) {
1001
			return true;
1002
		}
1003
1004
		// We test for equality - if one of the versions doesn't exist, this will be false.
1005
1006
		// TODO: DB Abstraction: if statement here:
1007
		$stagesAreEqual = DB::prepared_query(
1008
			"SELECT CASE WHEN \"$table1\".\"Version\"=\"$table2\".\"Version\" THEN 1 ELSE 0 END
1009
			 FROM \"$table1\" INNER JOIN \"$table2\" ON \"$table1\".\"ID\" = \"$table2\".\"ID\"
1010
			 AND \"$table1\".\"ID\" = ?",
1011
			array($this->owner->ID)
1012
		)->value();
1013
1014
		return !$stagesAreEqual;
1015
	}
1016
1017
	/**
1018
	 * Get a list of versions for this object, optionally with additional SQL parameters
1019
	 *
1020
	 * @param string $filter
1021
	 * @param string $sort
1022
	 * @param string $limit
1023
	 * @param string $join Deprecated, use leftJoin($table, $joinClause) instead
1024
	 * @param string $having
1025
	 * @return DataList
1026
	 */
1027
	public function Versions($filter = "", $sort = "", $limit = "", $join = "", $having = "") {
1028
		return $this->allVersions($filter, $sort, $limit, $join, $having);
1029
	}
1030
1031
	/**
1032
	 * Get a list of versions for this object, optionally with additional SQL parameters
1033
	 *
1034
	 * @param  string $filter
1035
	 * @param  string $sort
1036
	 * @param  string $limit
1037
	 * @param  string $join Deprecated, use leftJoin($table, $joinClause) instead
1038
	 * @param  string $having
1039
	 * @return DataList
1040
	 */
1041
	public function allVersions($filter = "", $sort = "", $limit = "", $join = "", $having = "") {
1042
		// Make sure the table names are not postfixed (e.g. _Live)
1043
		$oldMode = self::get_reading_mode();
1044
		self::reading_stage('Stage');
1045
1046
		$list = DataObject::get(get_class($this->owner), $filter, $sort, $join, $limit);
1047
		if($having) $having = $list->having($having);
0 ignored issues
show
Unused Code introduced by
$having is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
1048
1049
		$query = $list->dataQuery()->query();
1050
1051
		foreach($query->getFrom() as $table => $tableJoin) {
1052
			if(is_string($tableJoin) && $tableJoin[0] == '"') {
1053
				$baseTable = str_replace('"','',$tableJoin);
1054
			} elseif(is_string($tableJoin) && substr($tableJoin,0,5) != 'INNER') {
1055
				$query->setFrom(array(
1056
					$table => "LEFT JOIN \"$table\" ON \"$table\".\"RecordID\"=\"{$baseTable}_versions\".\"RecordID\""
0 ignored issues
show
Bug introduced by
The variable $baseTable does not seem to be defined for all execution paths leading up to this point.

If you define a variable conditionally, it can happen that it is not defined for all execution paths.

Let’s take a look at an example:

function myFunction($a) {
    switch ($a) {
        case 'foo':
            $x = 1;
            break;

        case 'bar':
            $x = 2;
            break;
    }

    // $x is potentially undefined here.
    echo $x;
}

In the above example, the variable $x is defined if you pass “foo” or “bar” as argument for $a. However, since the switch statement has no default case statement, if you pass any other value, the variable $x would be undefined.

Available Fixes

  1. Check for existence of the variable explicitly:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        if (isset($x)) { // Make sure it's always set.
            echo $x;
        }
    }
    
  2. Define a default value for the variable:

    function myFunction($a) {
        $x = ''; // Set a default which gets overridden for certain paths.
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        echo $x;
    }
    
  3. Add a value for the missing path:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
    
            // We add support for the missing case.
            default:
                $x = '';
                break;
        }
    
        echo $x;
    }
    
Loading history...
1057
						. " AND \"$table\".\"Version\" = \"{$baseTable}_versions\".\"Version\""
1058
				));
1059
			}
1060
			$query->renameTable($table, $table . '_versions');
1061
		}
1062
1063
		// Add all <basetable>_versions columns
1064 View Code Duplication
		foreach(Config::inst()->get('Versioned', 'db_for_versions_table') as $name => $type) {
0 ignored issues
show
Bug introduced by
The expression \Config::inst()->get('Ve...db_for_versions_table') of type array|integer|double|string|boolean is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
1065
			$query->selectField(sprintf('"%s_versions"."%s"', $baseTable, $name), $name);
1066
		}
1067
1068
		$query->addWhere(array(
1069
			"\"{$baseTable}_versions\".\"RecordID\" = ?" => $this->owner->ID
1070
		));
1071
		$query->setOrderBy(($sort) ? $sort
1072
			: "\"{$baseTable}_versions\".\"LastEdited\" DESC, \"{$baseTable}_versions\".\"Version\" DESC");
1073
1074
		$records = $query->execute();
1075
		$versions = new ArrayList();
1076
1077
		foreach($records as $record) {
1078
			$versions->push(new Versioned_Version($record));
1079
		}
1080
1081
		Versioned::set_reading_mode($oldMode);
1082
		return $versions;
1083
	}
1084
1085
	/**
1086
	 * Compare two version, and return the differences between them.
1087
	 *
1088
	 * @param string $from The version to compare from
1089
	 * @param string $to   The version to compare to
1090
	 * @return DataObject
1091
	 */
1092
	public function compareVersions($from, $to) {
1093
		$fromRecord = Versioned::get_version($this->owner->class, $this->owner->ID, $from);
1094
		$toRecord = Versioned::get_version($this->owner->class, $this->owner->ID, $to);
1095
1096
		$diff = new DataDifferencer($fromRecord, $toRecord);
0 ignored issues
show
Bug introduced by
It seems like $toRecord defined by \Versioned::get_version(... $this->owner->ID, $to) on line 1094 can be null; however, DataDifferencer::__construct() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
1097
1098
		return $diff->diffedData();
1099
	}
1100
1101
	/**
1102
	 * Return the base table - the class that directly extends DataObject.
1103
	 *
1104
	 * @param string $stage Override the stage used
1105
	 * @return string
1106
	 */
1107
	public function baseTable($stage = null) {
1108
		$tableClasses = ClassInfo::dataClassesFor($this->owner->class);
1109
		$baseClass = array_shift($tableClasses);
1110
1111
		if(!$stage || $stage == $this->defaultStage) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $stage of type string|null is loosely compared to false; this is ambiguous if the string can be empty. You might want to explicitly use === null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
1112
			return $baseClass;
1113
		}
1114
1115
		return $baseClass . "_$stage";
1116
	}
1117
1118
	//-----------------------------------------------------------------------------------------------//
1119
1120
1121
	/**
1122
	 * Determine if the current user is able to set the given site stage / archive
1123
	 *
1124
	 * @param SS_HTTPRequest $request
1125
	 * @return bool
1126
	 */
1127
	public static function can_choose_site_stage($request) {
1128
		// Request is allowed if stage isn't being modified
1129
		if((!$request->getVar('stage') || $request->getVar('stage') === static::get_live_stage())
1130
			&& !$request->getVar('archiveDate')
1131
		) {
1132
			return true;
1133
		}
1134
1135
		// Check permissions with member ID in session.
1136
		$member = Member::currentUser();
1137
		$permissions = Config::inst()->get(get_called_class(), 'non_live_permissions');
1138
		return $member && Permission::checkMember($member, $permissions);
1139
	}
1140
1141
	/**
1142
	 * Choose the stage the site is currently on:
1143
	 * - If $_GET['stage'] is set, then it will use that stage, and store it in the session.
1144
	 * - If $_GET['archiveDate'] is set, it will use that date, and store it in the session.
1145
	 * - If neither of these are set, it checks the session, otherwise the stage is set to 'Live'.
1146
	 *
1147
	 * @param Session $session Optional session within which to store the resulting stage
0 ignored issues
show
Bug introduced by
There is no parameter named $session. Was it maybe removed?

This check looks for PHPDoc comments describing methods or function parameters that do not exist on the corresponding method or function.

Consider the following example. The parameter $italy is not defined by the method finale(...).

/**
 * @param array $germany
 * @param array $island
 * @param array $italy
 */
function finale($germany, $island) {
    return "2:1";
}

The most likely cause is that the parameter was removed, but the annotation was not.

Loading history...
1148
	 */
1149
	public static function choose_site_stage() {
1150
		// Check any pre-existing session mode
1151
		$preexistingMode = Session::get('readingMode');
1152
1153
		// Determine the reading mode
1154
		if(isset($_GET['stage'])) {
1155
			$stage = ucfirst(strtolower($_GET['stage']));
1156
			if(!in_array($stage, array('Stage', 'Live'))) $stage = 'Live';
1157
			$mode = 'Stage.' . $stage;
1158
		} elseif (isset($_GET['archiveDate']) && strtotime($_GET['archiveDate'])) {
1159
			$mode = 'Archive.' . $_GET['archiveDate'];
1160
		} elseif($preexistingMode) {
1161
			$mode = $preexistingMode;
1162
		} else {
1163
			$mode = self::DEFAULT_MODE;
1164
		}
1165
1166
		// Save reading mode
1167
		Versioned::set_reading_mode($mode);
1168
1169
		// Try not to store the mode in the session if not needed
1170
		if(($preexistingMode && $preexistingMode !== $mode)
1171
			|| (!$preexistingMode && $mode !== self::DEFAULT_MODE)
1172
		) {
1173
			Session::set('readingMode', $mode);
1174
		}
1175
1176
		if(!headers_sent() && !Director::is_cli()) {
1177
			if(Versioned::current_stage() == 'Live') {
1178
				// clear the cookie if it's set
1179
				if(Cookie::get('bypassStaticCache')) {
0 ignored issues
show
Bug Best Practice introduced by
The expression \Cookie::get('bypassStaticCache') of type string|null is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
1180
					Cookie::force_expiry('bypassStaticCache', null, null, false, true /* httponly */);
1181
				}
1182
			} else {
1183
				// set the cookie if it's cleared
1184
				if(!Cookie::get('bypassStaticCache')) {
0 ignored issues
show
Bug Best Practice introduced by
The expression \Cookie::get('bypassStaticCache') of type string|null is loosely compared to false; this is ambiguous if the string can be empty. You might want to explicitly use === null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
1185
					Cookie::set('bypassStaticCache', '1', 0, null, null, false, true /* httponly */);
1186
				}
1187
			}
1188
		}
1189
	}
1190
1191
	/**
1192
	 * Set the current reading mode.
1193
	 *
1194
	 * @param string $mode
1195
	 */
1196
	public static function set_reading_mode($mode) {
1197
		Versioned::$reading_mode = $mode;
1198
	}
1199
1200
	/**
1201
	 * Get the current reading mode.
1202
	 *
1203
	 * @return string
1204
	 */
1205
	public static function get_reading_mode() {
1206
		return Versioned::$reading_mode;
1207
	}
1208
1209
	/**
1210
	 * Get the name of the 'live' stage.
1211
	 *
1212
	 * @return string
1213
	 */
1214
	public static function get_live_stage() {
1215
		return "Live";
1216
	}
1217
1218
	/**
1219
	 * Get the current reading stage.
1220
	 *
1221
	 * @return string
1222
	 */
1223
	public static function current_stage() {
1224
		$parts = explode('.', Versioned::get_reading_mode());
1225
1226
		if($parts[0] == 'Stage') {
1227
			return $parts[1];
1228
		}
1229
	}
1230
1231
	/**
1232
	 * Get the current archive date.
1233
	 *
1234
	 * @return string
1235
	 */
1236
	public static function current_archived_date() {
1237
		$parts = explode('.', Versioned::get_reading_mode());
1238
		if($parts[0] == 'Archive') return $parts[1];
1239
	}
1240
1241
	/**
1242
	 * Set the reading stage.
1243
	 *
1244
	 * @param string $stage
1245
	 */
1246
	public static function reading_stage($stage) {
1247
		Versioned::set_reading_mode('Stage.' . $stage);
1248
	}
1249
1250
	/**
1251
	 * Set the reading archive date.
1252
	 *
1253
	 * @param string $date
1254
	 */
1255
	public static function reading_archived_date($date) {
1256
		Versioned::set_reading_mode('Archive.' . $date);
1257
	}
1258
1259
	/**
1260
	 * Get a singleton instance of a class in the given stage.
1261
	 *
1262
	 * @param string $class  The name of the class
1263
	 * @param string $stage  The name of the stage
1264
	 * @param string $filter A filter to be inserted into the WHERE clause
1265
	 * @param bool   $cache  Whether to load from the cache instead of fresh from the database
1266
	 * @param string $sort   A sort expression to be inserted into the ORDER BY clause.
1267
	 *
1268
	 * @return DataObject
1269
	 */
1270
	public static function get_one_by_stage($class, $stage, $filter = '', $cache = true, $sort = '') {
0 ignored issues
show
Unused Code introduced by
The parameter $cache is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
1271
		// TODO: No identity cache operating
1272
		$items = self::get_by_stage($class, $stage, $filter, $sort, null, 1);
1273
1274
		return $items->First();
1275
	}
1276
1277
	/**
1278
	 * Gets the current version number of a specific record.
1279
	 *
1280
	 * @param string  $class The classname of the desired object
1281
	 * @param string  $stage The name of the stage to load from
1282
	 * @param int     $id    The object's ID
1283
	 * @param bool    $cache Whether to load from the cache instead of fresh from the database
1284
	 *
1285
	 * @return int
1286
	 */
1287
	public static function get_versionnumber_by_stage($class, $stage, $id, $cache = true) {
1288
		$baseClass = ClassInfo::baseDataClass($class);
1289
		$stageTable = ($stage == 'Stage') ? $baseClass : "{$baseClass}_{$stage}";
1290
1291
		// cached call
1292
		if($cache && isset(self::$cache_versionnumber[$baseClass][$stage][$id])) {
1293
			return self::$cache_versionnumber[$baseClass][$stage][$id];
1294
		}
1295
1296
		// get version as performance-optimized SQL query (gets called for each object of this class in the database)
1297
		$version = DB::query("SELECT \"Version\" FROM \"$stageTable\" WHERE \"ID\" = $id")->value();
1298
1299
		// cache value (if required)
1300
		if($cache) {
1301
			if(!isset(self::$cache_versionnumber[$baseClass])) {
1302
				self::$cache_versionnumber[$baseClass] = array();
1303
			}
1304
1305
			if(!isset(self::$cache_versionnumber[$baseClass][$stage])) {
1306
				self::$cache_versionnumber[$baseClass][$stage] = array();
1307
			}
1308
1309
			self::$cache_versionnumber[$baseClass][$stage][$id] = $version;
1310
		}
1311
1312
		return $version;
1313
	}
1314
1315
	/**
1316
	 * Prepopulate the cache for Versioned::get_versionnumber_by_stage() for a list of record IDs, for more efficient
1317
	 * database querying. If $idList is null, then every object will be pre-cached.
1318
	 *
1319
	 * @param string $class  The object class to prepopulate version numbers for
1320
	 * @param string $stage  The stage to prepopulate version numbers from
1321
	 * @param array  $idList A whitelist of IDs to use when prepopulating
1322
	 */
1323
	public static function prepopulate_versionnumber_cache($class, $stage, $idList = null) {
1324
		if (!Config::inst()->get('Versioned', 'prepopulate_versionnumber_cache')) {
1325
			return;
1326
		}
1327
		$filter = "";
1328
		$parameters = array();
1329
		if($idList) {
1330
			// Validate the ID list
1331
			foreach($idList as $id) {
1332
				if(!is_numeric($id)) {
1333
					user_error("Bad ID passed to Versioned::prepopulate_versionnumber_cache() in \$idList: " . $id,
1334
					E_USER_ERROR);
1335
				}
1336
			}
1337
			$filter = 'WHERE "ID" IN ('.DB::placeholders($idList).')';
1338
			$parameters = $idList;
1339
		}
1340
1341
		$baseClass = ClassInfo::baseDataClass($class);
1342
		$stageTable = ($stage == 'Stage') ? $baseClass : "{$baseClass}_{$stage}";
1343
1344
		$versions = DB::prepared_query("SELECT \"ID\", \"Version\" FROM \"$stageTable\" $filter", $parameters)->map();
1345
1346
		foreach($versions as $id => $version) {
1347
			self::$cache_versionnumber[$baseClass][$stage][$id] = $version;
1348
		}
1349
	}
1350
1351
	/**
1352
	 * Get a set of class instances by the given stage.
1353
	 *
1354
	 * @param string     $class          The name of the class.
1355
	 * @param string     $stage          The name of the stage.
1356
	 * @param string     $filter         A filter to be inserted into the WHERE clause.
1357
	 * @param string     $sort           A sort expression to be inserted into the ORDER BY clause.
1358
	 * @param string     $join           Deprecated, use leftJoin($table, $joinClause) instead
1359
	 * @param string|int $limit          A limit on the number of records returned from the database.
1360
	 * @param string     $containerClass The container class for the result set (default is DataList)
1361
	 *
1362
	 * @return DataList A modified DataList designated to the specified stage
1363
	 */
1364
	public static function get_by_stage($class, $stage, $filter = '', $sort = '', $join = '', $limit = '',
1365
			$containerClass = 'DataList') {
1366
1367
		$result = DataObject::get($class, $filter, $sort, $join, $limit, $containerClass);
1368
		return $result->setDataQueryParam(array(
1369
			'Versioned.mode' => 'stage',
1370
			'Versioned.stage' => $stage
1371
		));
1372
	}
1373
1374
	/**
1375
	 * Delete this item from the specified stage.
1376
	 *
1377
	 * @param string $stage
1378
	 */
1379
	public function deleteFromStage($stage) {
1380
		$oldMode = Versioned::get_reading_mode();
1381
		Versioned::reading_stage($stage);
1382
		$clone = clone $this->owner;
1383
		$result = $clone->delete();
0 ignored issues
show
Bug introduced by
Are you sure the assignment to $result is correct as $clone->delete() (which targets DataObject::delete()) seems to always return null.

This check looks for function or method calls that always return null and whose return value is assigned to a variable.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
$object = $a->getObject();

The method getObject() can return nothing but null, so it makes no sense to assign that value to a variable.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
1384
		Versioned::set_reading_mode($oldMode);
1385
1386
		// Fix the version number cache (in case you go delete from stage and then check ExistsOnLive)
1387
		$baseClass = ClassInfo::baseDataClass($this->owner->class);
1388
		self::$cache_versionnumber[$baseClass][$stage][$this->owner->ID] = null;
1389
1390
		return $result;
1391
	}
1392
1393
	/**
1394
	 * Write this item to the specified stage.
1395
	 *
1396
	 * @param string $stage       The stage to write this item to
1397
	 * @param bool   $forceInsert Whether to force an INSERT query over an UPDATE query
1398
	 * @return int The ID of the item being written
1399
	 */
1400 View Code Duplication
	public function writeToStage($stage, $forceInsert = false) {
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
1401
		$oldMode = Versioned::get_reading_mode();
1402
		Versioned::reading_stage($stage);
1403
1404
		$this->owner->forceChange();
1405
		$result = $this->owner->write(false, $forceInsert);
1406
		Versioned::set_reading_mode($oldMode);
1407
1408
		return $result;
1409
	}
1410
1411
	/**
1412
	 * Roll the draft version of this object to match the published one.
1413
	 *
1414
	 * Caution: Doesn't overwrite the object properties with the rolled back version.
1415
	 *
1416
	 * @param string|int $version Either the string 'Live' or a version number
1417
	 */
1418
	public function doRollbackTo($version) {
1419
		$this->owner->extend('onBeforeRollback', $version);
1420
		$this->publish($version, "Stage", true);
1421
1422
		$this->owner->writeWithoutVersion();
1423
1424
		$this->owner->extend('onAfterRollback', $version);
1425
	}
1426
1427
	/**
1428
	 * Return the latest version of the given object.
1429
	 *
1430
	 * @param string $class The classname of the object to lookup
1431
	 * @param string $id    The object of the ID to retrieve
1432
	 * @return DataObject
1433
	 */
1434 View Code Duplication
	public static function get_latest_version($class, $id) {
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
1435
		$baseClass = ClassInfo::baseDataClass($class);
1436
		$list = DataList::create($baseClass)
1437
			->where("\"$baseClass\".\"RecordID\" = $id")
1438
			->setDataQueryParam("Versioned.mode", "latest_versions");
1439
1440
		return $list->First();
1441
	}
1442
1443
	/**
1444
	 * Returns whether the current record is the latest one.
1445
	 *
1446
	 * @todo Performance - could do this directly via SQL.
1447
	 *
1448
	 * @see get_latest_version()
1449
	 * @see latestPublished
1450
	 *
1451
	 * @return bool
1452
	 */
1453
	public function isLatestVersion() {
1454
		$version = self::get_latest_version($this->owner->class, $this->owner->ID);
1455
		if($version) {
1456
		return ($version->Version == $this->owner->Version);
1457
	}
1458
1459
		return false;
1460
	}
1461
1462
	/**
1463
	 * Return the equivalent of a DataList::create() call, querying the latest version of each object stored in the
1464
	 * (class)_versions tables. In particular, this will query deleted records as well as active ones.
1465
	 *
1466
	 * @param string $class The type of object to lookup
1467
	 * @param string $filter An optional SQL comparison to add to the WHERE clause
1468
	 * @param string $sort An optional SQL statement to add to the SORT clause
1469
	 */
1470
	public static function get_including_deleted($class, $filter = "", $sort = "") {
1471
		$list = DataList::create($class)
1472
			->where($filter)
1473
			->sort($sort)
1474
			->setDataQueryParam("Versioned.mode", "latest_versions");
1475
1476
		return $list;
1477
	}
1478
1479
	/**
1480
	 * Return the specific version of the given ID.
1481
	 *
1482
	 * Caution: The record is retrieved as a DataObject, but saving back modifications via write() will create a new
1483
	 * version, rather than modifying the existing one.
1484
	 *
1485
	 * @param string $class   The type of object to lookup
1486
	 * @param int    $id      The ID of the object to retrieve
1487
	 * @param int    $version The desired version of the object
1488
	 * @return DataObject
1489
	 */
1490 View Code Duplication
	public static function get_version($class, $id, $version) {
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
1491
		$baseClass = ClassInfo::baseDataClass($class);
1492
		$list = DataList::create($baseClass)
1493
			->where("\"$baseClass\".\"RecordID\" = $id")
1494
			->where("\"$baseClass\".\"Version\" = " . (int)$version)
1495
			->setDataQueryParam("Versioned.mode", 'all_versions');
1496
1497
		return $list->First();
1498
	}
1499
1500
	/**
1501
	 * Return a list of all versions for a given id.
1502
	 *
1503
	 * @param string $class The type of object to lookup
1504
	 * @param int    $id    The ID of the object to retrieve
1505
	 *
1506
	 * @return DataList
1507
	 */
1508
	public static function get_all_versions($class, $id) {
1509
		$baseClass = ClassInfo::baseDataClass($class);
1510
		$list = DataList::create($class)
1511
			->where("\"$baseClass\".\"RecordID\" = $id")
1512
			->setDataQueryParam('Versioned.mode', 'all_versions');
1513
1514
		return $list;
1515
	}
1516
1517
	/**
1518
	 * @param array $labels
1519
	 */
1520
	public function updateFieldLabels(&$labels) {
1521
		$labels['Versions'] = _t('Versioned.has_many_Versions', 'Versions', 'Past Versions of this page');
1522
	}
1523
1524
	public function updateCMSFields(FieldList $fields) {
1525
		// remove the version field from the CMS as this should be left
1526
		// entirely up to the extension (not the cms user).
1527
		$fields->removeByName('Version');
1528
	}
1529
1530
	/**
1531
	 * Ensure version ID is reset to 0 on duplicate
1532
	 *
1533
	 * @param DataObject $source Record this was duplicated from
1534
	 * @param bool $doWrite
1535
	 */
1536
	public function onBeforeDuplicate($source, $doWrite) {
0 ignored issues
show
Unused Code introduced by
The parameter $source is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
Unused Code introduced by
The parameter $doWrite is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
1537
		$this->owner->Version = 0;
0 ignored issues
show
Documentation introduced by
The property Version does not exist on object<DataObject>. Since you implemented __set, maybe consider adding a @property annotation.

Since your code implements the magic setter _set, this function will be called for any write access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

Since the property has write access only, you can use the @property-write annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
1538
	}
1539
1540
	/**
1541
	 * Clear the cached version numbers from previous queries.
1542
	 */
1543
	public function flushCache() {
1544
		self::$cache_versionnumber = array();
1545
	}
1546
1547
	/**
1548
	 * Returns a piece of text to keep DataObject cache keys appropriately specific.
1549
	 *
1550
	 * @return string
1551
	 */
1552
	public function cacheKeyComponent() {
1553
		return 'versionedmode-'.self::get_reading_mode();
1554
	}
1555
1556
	/**
1557
	 * Returns an array of possible stages.
1558
	 *
1559
	 * @return array
1560
	 */
1561
	public function getVersionedStages() {
1562
		return $this->stages;
1563
	}
1564
1565
	/**
1566
	 * @return string
1567
	 */
1568
	public function getDefaultStage() {
1569
		return $this->defaultStage;
1570
	}
1571
1572
	public static function get_template_global_variables() {
1573
		return array(
1574
			'CurrentReadingMode' => 'get_reading_mode'
1575
		);
1576
	}
1577
}
1578
1579
/**
1580
 * Represents a single version of a record.
1581
 *
1582
 * @package framework
1583
 * @subpackage model
1584
 *
1585
 * @see Versioned
1586
 */
1587
class Versioned_Version extends ViewableData {
1588
1589
	/** @var array */
1590
	protected $record;
1591
1592
	/** @var DataObject */
1593
	protected $object;
1594
1595
	public function __construct($record) {
1596
		$this->record = $record;
1597
		$record['ID'] = $record['RecordID'];
1598
		$className = $record['ClassName'];
1599
1600
		$this->object = ClassInfo::exists($className) ? new $className($record) : new DataObject($record);
1601
		$this->failover = $this->object;
1602
1603
		parent::__construct();
1604
	}
1605
1606
	/**
1607
	 * Get a CSS classname to use representing whether this version was published or not.
1608
	 *
1609
	 * @return string
1610
	 */
1611
	public function PublishedClass() {
1612
		return $this->record['WasPublished'] ? 'published' : 'internal';
1613
	}
1614
1615
	/**
1616
	 * Gets this version's author (the person who saved to Stage).
1617
	 *
1618
	 * @return Member
1619
	 */
1620
	public function Author() {
1621
		return Member::get()->byId($this->record['AuthorID']);
1622
	}
1623
1624
	/**
1625
	 * Get this version's publisher.
1626
	 *
1627
	 * @return Member
1628
	 */
1629
	public function Publisher() {
1630
		if (!$this->record['WasPublished']) {
1631
			return null;
1632
		}
1633
1634
		return Member::get()->byId($this->record['PublisherID']);
1635
	}
1636
1637
	/**
1638
	 * Determines if this version was ever published.
1639
	 *
1640
	 * @return bool
1641
	 */
1642
	public function Published() {
1643
		return !empty($this->record['WasPublished']);
1644
	}
1645
1646
	/**
1647
	 * Copied from DataObject to allow access via dot notation.
1648
	 *
1649
	 * @param string $fieldName
1650
	 * @return mixed
1651
	 */
1652
	public function relField($fieldName) {
1653
		$component = $this;
1654
1655
		if(strpos($fieldName, '.') !== false) {
1656
			$parts = explode('.', $fieldName);
1657
			$fieldName = array_pop($parts);
1658
1659
			// Traverse dot syntax
1660 View Code Duplication
			foreach($parts as $relation) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
1661
				if($component instanceof SS_List) {
1662
					if(method_exists($component,$relation)) {
1663
						$component = $component->$relation();
1664
					} else {
1665
						$component = $component->relation($relation);
1666
					}
1667
				} else {
1668
					$component = $component->$relation();
1669
				}
1670
			}
1671
		}
1672
1673
		// Unlike has-one's, these "relations" can return false
1674
		if($component) {
1675
			if ($component->hasMethod($fieldName)) {
1676
				return $component->$fieldName();
1677
			}
1678
1679
			return $component->$fieldName;
1680
		}
1681
	}
1682
}
1683