Completed
Push — master ( 9e3f76...51d53f )
by Hamish
10:45
created

DataObjectSchema::cacheDatabaseFields()   D

Complexity

Conditions 13
Paths 433

Size

Total Lines 57
Code Lines 33

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 13
eloc 33
c 1
b 0
f 0
nc 433
nop 1
dl 0
loc 57
rs 4.6926

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
use SilverStripe\Framework\Core\Configurable;
4
use SilverStripe\Framework\Core\Injectable;
5
use SilverStripe\Model\FieldType\DBComposite;
6
7
/**
8
 * Provides dataobject and database schema mapping functionality
9
 */
10
class DataObjectSchema {
11
	use Injectable;
12
	use Configurable;
13
14
	/**
15
	 * Default separate for table namespaces. Can be set to any string for
16
	 * databases that do not support some characters.
17
	 *
18
	 * Defaults to \ to to conform to 3.x convention.
19
	 *
20
	 * @config
21
	 * @var string
22
	 */
23
	private static $table_namespace_separator = '\\';
24
25
	/**
26
	 * Cache of database fields
27
	 *
28
	 * @var array
29
	 */
30
	protected $databaseFields = [];
31
32
	/**
33
	 * Cache of composite database field
34
	 *
35
	 * @var array
36
	 */
37
	protected $compositeFields = [];
38
39
	/**
40
	 * Cache of table names
41
	 *
42
	 * @var array
43
	 */
44
	protected $tableNames = [];
45
46
	/**
47
	 * Clear cached table names
48
	 */
49
	public function reset() {
50
		$this->tableNames = [];
51
		$this->databaseFields = [];
52
		$this->compositeFields = [];
53
	}
54
55
	/**
56
	 * Get all table names
57
	 *
58
	 * @return array
59
	 */
60
	public function getTableNames() {
61
		$this->cacheTableNames();
62
		return $this->tableNames;
63
	}
64
65
	/**
66
	 * Given a DataObject class and a field on that class, determine the appropriate SQL for
67
	 * selecting / filtering on in a SQL string. Note that $class must be a valid class, not an
68
	 * arbitrary table.
69
	 *
70
	 * The result will be a standard ANSI-sql quoted string in "Table"."Column" format.
71
	 *
72
	 * @param string $class Class name (not a table).
73
	 * @param string $field Name of field that belongs to this class (or a parent class)
74
	 * @return string The SQL identifier string for the corresponding column for this field
75
	 */
76
	public function sqlColumnForField($class, $field) {
77
		$table = $this->tableForField($class, $field);
78
		if(!$table) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $table 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...
79
			throw new InvalidArgumentException("\"{$field}\" is not a field on class \"{$class}\"");
80
		}
81
		return "\"{$table}\".\"{$field}\"";
82
	}
83
84
	/**
85
	 * Get table name for the given class.
86
	 *
87
	 * Note that this does not confirm a table actually exists (or should exist), but returns
88
	 * the name that would be used if this table did exist.
89
	 *
90
	 * @param string $class
91
	 * @return string Returns the table name, or null if there is no table
92
	 */
93
	public function tableName($class) {
94
		$tables = $this->getTableNames();
95
		$class = ClassInfo::class_name($class);
96
		if(isset($tables[$class])) {
97
			return $tables[$class];
98
		}
99
		return null;
100
	}
101
	/**
102
	 * Returns the root class (the first to extend from DataObject) for the
103
	 * passed class.
104
	 *
105
	 * @param string|object $class
106
	 * @return string
107
	 * @throws InvalidArgumentException
108
	 */
109
	public function baseDataClass($class) {
110
		$class = ClassInfo::class_name($class);
111
		$current = $class;
112
		while ($next = get_parent_class($current)) {
113
			if ($next === 'DataObject') {
114
				return $current;
115
			}
116
			$current = $next;
117
		}
118
		throw new InvalidArgumentException("$class is not a subclass of DataObject");
119
	}
120
121
	/**
122
	 * Get the base table
123
	 *
124
	 * @param string|object $class
125
	 * @return string
126
	 */
127
	public function baseDataTable($class) {
128
		return $this->tableName($this->baseDataClass($class));
129
	}
130
131
	/**
132
	 * Find the class for the given table
133
	 *
134
	 * @param string $table
135
	 * @return string|null The FQN of the class, or null if not found
136
	 */
137
	public function tableClass($table) {
138
		$tables = $this->getTableNames();
139
		$class = array_search($table, $tables, true);
140
		if($class) {
141
			return $class;
142
		}
143
144
		// If there is no class for this table, strip table modifiers (e.g. _Live / _versions)
145
		// from the end and re-attempt a search.
146
		if(preg_match('/^(?<class>.+)(_[^_]+)$/i', $table, $matches)) {
147
			$table = $matches['class'];
148
			$class = array_search($table, $tables, true);
149
			if($class) {
150
				return $class;
151
			}
152
		}
153
		return null;
154
	}
155
156
	/**
157
	 * Cache all table names if necessary
158
	 */
159
	protected function cacheTableNames() {
160
		if($this->tableNames) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->tableNames 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...
161
			return;
162
		}
163
		$this->tableNames = [];
164
		foreach(ClassInfo::subclassesFor('DataObject') as $class) {
165
			if($class === 'DataObject') {
166
				continue;
167
			}
168
			$table = $this->buildTableName($class);
169
170
			// Check for conflicts
171
			$conflict = array_search($table, $this->tableNames, true);
172
			if($conflict) {
173
				throw new LogicException(
174
					"Multiple classes (\"{$class}\", \"{$conflict}\") map to the same table: \"{$table}\""
175
				);
176
			}
177
			$this->tableNames[$class] = $table;
178
		}
179
	}
180
181
	/**
182
	 * Generate table name for a class.
183
	 *
184
	 * Note: some DB schema have a hard limit on table name length. This is not enforced by this method.
185
	 * See dev/build errors for details in case of table name violation.
186
	 *
187
	 * @param string $class
188
	 * @return string
189
	 */
190
	protected function buildTableName($class) {
191
		$table = Config::inst()->get($class, 'table_name', Config::UNINHERITED);
192
193
		// Generate default table name
194
		if(!$table) {
195
			$separator = $this->config()->table_namespace_separator;
0 ignored issues
show
Documentation introduced by
The property table_namespace_separator 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...
196
			$table = str_replace('\\', $separator, trim($class, '\\'));
197
		}
198
199
		return $table;
200
	}
201
202
	/**
203
	 * Return the complete map of fields to specification on this object, including fixed_fields.
204
	 * "ID" will be included on every table.
205
	 *
206
	 * @param string $class Class name to query from
207
	 * @return array Map of fieldname to specification, similiar to {@link DataObject::$db}.
208
	 */
209
	public function databaseFields($class) {
210
		$class = ClassInfo::class_name($class);
211
		if($class === 'DataObject') {
212
			return [];
213
		}
214
		$this->cacheDatabaseFields($class);
215
		return $this->databaseFields[$class];
216
	}
217
218
	/**
219
	 * Returns a list of all the composite if the given db field on the class is a composite field.
220
	 * Will check all applicable ancestor classes and aggregate results.
221
	 *
222
	 * Can be called directly on an object. E.g. Member::composite_fields(), or Member::composite_fields(null, true)
223
	 * to aggregate.
224
	 *
225
	 * Includes composite has_one (Polymorphic) fields
226
	 *
227
	 * @param string $class Name of class to check
228
	 * @param bool $aggregated Include fields in entire hierarchy, rather than just on this table
229
	 * @return array List of composite fields and their class spec
230
	 */
231
	public function compositeFields($class, $aggregated = true) {
232
		$class = ClassInfo::class_name($class);
233
		if($class === 'DataObject') {
234
			return [];
235
		}
236
		$this->cacheDatabaseFields($class);
237
238
		// Get fields for this class
239
		$compositeFields = $this->compositeFields[$class];
240
		if(!$aggregated) {
241
			return $compositeFields;
242
		}
243
244
		// Recursively merge
245
		$parentFields = $this->compositeFields(get_parent_class($class));
246
		return array_merge($compositeFields, $parentFields);
247
	}
248
249
	/**
250
	 * Cache all database and composite fields for the given class.
251
	 * Will do nothing if already cached
252
	 *
253
	 * @param string $class Class name to cache
254
	 */
255
	protected function cacheDatabaseFields($class) {
256
		// Skip if already cached
257
		if (isset($this->databaseFields[$class]) && isset($this->compositeFields[$class])) {
258
			return;
259
		}
260
		$compositeFields = array();
261
		$dbFields = array();
262
263
		// Ensure fixed fields appear at the start
264
		$fixedFields = DataObject::config()->fixed_fields;
0 ignored issues
show
Documentation introduced by
The property fixed_fields 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...
265
		if(get_parent_class($class) === 'DataObject') {
266
			// Merge fixed with ClassName spec and custom db fields
267
			$dbFields = $fixedFields;
268
		} else {
269
			$dbFields['ID'] = $fixedFields['ID'];
270
		}
271
272
		// Check each DB value as either a field or composite field
273
		$db = Config::inst()->get($class, 'db', Config::UNINHERITED) ?: array();
274
		foreach($db as $fieldName => $fieldSpec) {
0 ignored issues
show
Bug introduced by
The expression $db 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...
275
			$fieldClass = strtok($fieldSpec, '(');
276
			if(singleton($fieldClass) instanceof DBComposite) {
277
				$compositeFields[$fieldName] = $fieldSpec;
278
			} else {
279
				$dbFields[$fieldName] = $fieldSpec;
280
			}
281
		}
282
283
		// Add in all has_ones
284
		$hasOne = Config::inst()->get($class, 'has_one', Config::UNINHERITED) ?: array();
285
		foreach($hasOne as $fieldName => $hasOneClass) {
0 ignored issues
show
Bug introduced by
The expression $hasOne 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...
286
			if($hasOneClass === 'DataObject') {
287
				$compositeFields[$fieldName] = 'PolymorphicForeignKey';
288
			} else {
289
				$dbFields["{$fieldName}ID"] = 'ForeignKey';
290
			}
291
		}
292
293
		// Merge composite fields into DB
294
		foreach($compositeFields as $fieldName => $fieldSpec) {
295
			$fieldObj = Object::create_from_string($fieldSpec, $fieldName);
296
			$fieldObj->setTable($class);
297
			$nestedFields = $fieldObj->compositeDatabaseFields();
298
			foreach($nestedFields as $nestedName => $nestedSpec) {
299
				$dbFields["{$fieldName}{$nestedName}"] = $nestedSpec;
300
			}
301
		}
302
303
		// Prevent field-less tables
304
		if(count($dbFields) < 2) {
305
			$dbFields = [];
306
		}
307
308
		// Return cached results
309
		$this->databaseFields[$class] = $dbFields;
310
		$this->compositeFields[$class] = $compositeFields;
311
	}
312
313
	/**
314
	 * Returns the table name in the class hierarchy which contains a given
315
	 * field column for a {@link DataObject}. If the field does not exist, this
316
	 * will return null.
317
	 *
318
	 * @param string $candidateClass
319
	 * @param string $fieldName
320
	 * @return string
321
	 */
322
	public function tableForField($candidateClass, $fieldName) {
323
		$class = $this->classForField($candidateClass, $fieldName);
324
		if($class) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $class of type null|string 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...
325
			return $this->tableName($class);
326
		}
327
		return null;
328
	}
329
330
	/**
331
	 * Returns the class name in the class hierarchy which contains a given
332
	 * field column for a {@link DataObject}. If the field does not exist, this
333
	 * will return null.
334
	 *
335
	 * @param string $candidateClass
336
	 * @param string $fieldName
337
	 * @return string
338
	 */
339
	public function classForField($candidateClass, $fieldName)  {
340
		// normalise class name
341
		$candidateClass = ClassInfo::class_name($candidateClass);
342
		if($candidateClass === 'DataObject') {
343
			return null;
344
		}
345
346
		// Short circuit for fixed fields
347
		$fixed = DataObject::config()->fixed_fields;
0 ignored issues
show
Documentation introduced by
The property fixed_fields 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...
348
		if(isset($fixed[$fieldName])) {
349
			return $this->baseDataClass($candidateClass);
350
		}
351
352
		// Find regular field
353
		while($candidateClass) {
354
			$fields = $this->databaseFields($candidateClass);
355
			if(isset($fields[$fieldName])) {
356
				return $candidateClass;
357
			}
358
			$candidateClass = get_parent_class($candidateClass);
359
		}
360
		return null;
361
	}
362
}
363