Completed
Push — master ( ef0340...cb24d1 )
by Sam
10:24
created

DataObjectSchema   D

Complexity

Total Complexity 118

Size/Duplication

Total Lines 927
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 7

Importance

Changes 0
Metric Value
dl 0
loc 927
rs 4.4444
c 0
b 0
f 0
wmc 118
lcom 1
cbo 7

31 Methods

Rating   Name   Duplication   Size   Complexity  
A reset() 0 5 1
A getTableNames() 0 4 1
A sqlColumnForField() 0 7 2
A tableName() 0 8 2
A baseDataClass() 0 11 3
A baseDataTable() 0 3 1
C fieldSpecs() 0 32 7
A fieldSpec() 0 4 2
A tableClass() 0 18 4
B cacheTableNames() 0 21 5
A buildTableName() 0 11 2
A databaseFields() 0 16 3
A databaseField() 0 4 2
A classHasTable() 0 4 1
A compositeFields() 0 17 3
A compositeField() 0 4 2
D cacheDatabaseFields() 0 57 13
A tableForField() 0 7 2
B classForField() 0 23 5
B manyManyComponent() 0 26 4
B parseBelongsManyManyComponent() 0 24 4
B manyManyExtraFieldsForComponent() 0 20 5
A hasManyComponent() 0 14 3
A hasOneComponent() 0 11 2
A belongsToComponent() 0 14 3
B parseManyManyComponent() 0 40 3
A getManyManyInverseRelationship() 0 13 4
C getRemoteJoinField() 0 68 12
C checkManyManyFieldClass() 0 47 7
A checkManyManyJoinClass() 0 16 3
C checkRelationClass() 0 30 7

How to fix   Complexity   

Complex Class

Complex classes like DataObjectSchema 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 DataObjectSchema, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace SilverStripe\ORM;
4
5
use Exception;
6
use SilverStripe\Core\Injector\Injectable;
7
use SilverStripe\Core\Config\Configurable;
8
use SilverStripe\ORM\FieldType\DBComposite;
9
use SilverStripe\Core\ClassInfo;
10
use SilverStripe\Core\Config\Config;
11
use SilverStripe\Core\Object;
12
use InvalidArgumentException;
13
use LogicException;
14
15
/**
16
 * Provides dataobject and database schema mapping functionality
17
 */
18
class DataObjectSchema {
19
20
	use Injectable;
21
	use Configurable;
22
23
	/**
24
	 * Default separate for table namespaces. Can be set to any string for
25
	 * databases that do not support some characters.
26
	 *
27
	 * Defaults to \ to to conform to 3.x convention.
28
	 *
29
	 * @config
30
	 * @var string
31
	 */
32
	private static $table_namespace_separator = '\\';
33
34
	/**
35
	 * Cache of database fields
36
	 *
37
	 * @var array
38
	 */
39
	protected $databaseFields = [];
40
41
	/**
42
	 * Cache of composite database field
43
	 *
44
	 * @var array
45
	 */
46
	protected $compositeFields = [];
47
48
	/**
49
	 * Cache of table names
50
	 *
51
	 * @var array
52
	 */
53
	protected $tableNames = [];
54
55
	/**
56
	 * Clear cached table names
57
	 */
58
	public function reset() {
59
		$this->tableNames = [];
60
		$this->databaseFields = [];
61
		$this->compositeFields = [];
62
	}
63
64
	/**
65
	 * Get all table names
66
	 *
67
	 * @return array
68
	 */
69
	public function getTableNames() {
70
		$this->cacheTableNames();
71
		return $this->tableNames;
72
	}
73
74
	/**
75
	 * Given a DataObject class and a field on that class, determine the appropriate SQL for
76
	 * selecting / filtering on in a SQL string. Note that $class must be a valid class, not an
77
	 * arbitrary table.
78
	 *
79
	 * The result will be a standard ANSI-sql quoted string in "Table"."Column" format.
80
	 *
81
	 * @param string $class Class name (not a table).
82
	 * @param string $field Name of field that belongs to this class (or a parent class)
83
	 * @return string The SQL identifier string for the corresponding column for this field
84
	 */
85
	public function sqlColumnForField($class, $field) {
86
		$table = $this->tableForField($class, $field);
87
		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...
88
			throw new InvalidArgumentException("\"{$field}\" is not a field on class \"{$class}\"");
89
		}
90
		return "\"{$table}\".\"{$field}\"";
91
	}
92
93
	/**
94
	 * Get table name for the given class.
95
	 *
96
	 * Note that this does not confirm a table actually exists (or should exist), but returns
97
	 * the name that would be used if this table did exist.
98
	 *
99
	 * @param string $class
100
	 * @return string Returns the table name, or null if there is no table
101
	 */
102
	public function tableName($class) {
103
		$tables = $this->getTableNames();
104
		$class = ClassInfo::class_name($class);
105
		if(isset($tables[$class])) {
106
			return $tables[$class];
107
		}
108
		return null;
109
	}
110
	/**
111
	 * Returns the root class (the first to extend from DataObject) for the
112
	 * passed class.
113
	 *
114
	 * @param string|object $class
115
	 * @return string
116
	 * @throws InvalidArgumentException
117
	 */
118
	public function baseDataClass($class) {
119
		$class = ClassInfo::class_name($class);
120
		$current = $class;
121
		while ($next = get_parent_class($current)) {
122
			if ($next === DataObject::class) {
123
				return $current;
124
			}
125
			$current = $next;
126
		}
127
		throw new InvalidArgumentException("$class is not a subclass of DataObject");
128
	}
129
130
	/**
131
	 * Get the base table
132
	 *
133
	 * @param string|object $class
134
	 * @return string
135
	 */
136
	public function baseDataTable($class) {
137
		return $this->tableName($this->baseDataClass($class));
138
	}
139
140
	/**
141
	 * fieldSpec should exclude virtual fields (such as composite fields), and only include fields with a db column.
142
	 */
143
	const DB_ONLY = 1;
144
145
	/**
146
	 * fieldSpec should only return fields that belong to this table, and not any ancestors
147
	 */
148
	const UNINHERITED = 2;
149
150
	/**
151
	 * fieldSpec should prefix all field specifications with the class name in RecordClass.Column(spec) format.
152
	 */
153
	const INCLUDE_CLASS = 4;
154
155
	/**
156
	 * Get all DB field specifications for a class, including ancestors and composite fields.
157
	 *
158
	 * @param string|DataObject $classOrInstance
159
	 * @param int $options Bitmask of options
160
	 *  - UNINHERITED Limit to only this table
161
	 *  - DB_ONLY Exclude virtual fields (such as composite fields), and only include fields with a db column.
162
	 *  - INCLUDE_CLASS Prefix the field specification with the class name in RecordClass.Column(spec) format.
163
	 * @return array List of fields, where the key is the field name and the value is the field specification.
164
	 */
165
	public function fieldSpecs($classOrInstance, $options = 0) {
166
		$class = ClassInfo::class_name($classOrInstance);
167
168
		// Validate options
169
		if (!is_int($options)) {
170
			throw new InvalidArgumentException("Invalid options " . var_export($options, true));
171
		}
172
		$uninherited = ($options & self::UNINHERITED) === self::UNINHERITED;
173
		$dbOnly = ($options & self::DB_ONLY) === self::DB_ONLY;
174
		$includeClass = ($options & self::INCLUDE_CLASS) === self::INCLUDE_CLASS;
175
176
		// Walk class hierarchy
177
		$db = [];
178
		$classes = $uninherited ? [$class] : ClassInfo::ancestry($class);
179
		foreach($classes as $tableClass) {
180
			// Find all fields on this class
181
			$fields = $this->databaseFields($tableClass, false);
182
183
			// Merge with composite fields
184
			if (!$dbOnly) {
185
				$compositeFields = $this->compositeFields($tableClass, false);
186
				$fields = array_merge($fields, $compositeFields);
187
			}
188
189
			// Record specification
190
			foreach ($fields as $name => $specification) {
191
				$prefix = $includeClass ? "{$tableClass}." : "";
192
				$db[$name] =  $prefix . $specification;
193
			}
194
		}
195
		return $db;
196
	}
197
198
199
	/**
200
	 * Get specifications for a single class field
201
	 *
202
	 * @param string|DataObject $classOrInstance Name or instance of class
203
	 * @param string $fieldName Name of field to retrieve
204
	 * @param int $options Bitmask of options
205
	 *  - UNINHERITED Limit to only this table
206
	 *  - DB_ONLY Exclude virtual fields (such as composite fields), and only include fields with a db column.
207
	 *  - INCLUDE_CLASS Prefix the field specification with the class name in RecordClass.Column(spec) format.
208
	 * @return string|null Field will be a string in FieldClass(args) format, or
209
	 * RecordClass.FieldClass(args) format if using INCLUDE_CLASS. Will be null if no field is found.
210
	 */
211
	public function fieldSpec($classOrInstance, $fieldName, $options = 0) {
212
		$specs = $this->fieldSpecs($classOrInstance, $options);
213
		return isset($specs[$fieldName]) ? $specs[$fieldName] : null;
214
	}
215
216
	/**
217
	 * Find the class for the given table
218
	 *
219
	 * @param string $table
220
	 * @return string|null The FQN of the class, or null if not found
221
	 */
222
	public function tableClass($table) {
223
		$tables = $this->getTableNames();
224
		$class = array_search($table, $tables, true);
225
		if($class) {
226
			return $class;
227
		}
228
229
		// If there is no class for this table, strip table modifiers (e.g. _Live / _versions)
230
		// from the end and re-attempt a search.
231
		if(preg_match('/^(?<class>.+)(_[^_]+)$/i', $table, $matches)) {
232
			$table = $matches['class'];
233
			$class = array_search($table, $tables, true);
234
			if($class) {
235
				return $class;
236
			}
237
		}
238
		return null;
239
	}
240
241
	/**
242
	 * Cache all table names if necessary
243
	 */
244
	protected function cacheTableNames() {
245
		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...
246
			return;
247
		}
248
		$this->tableNames = [];
249
		foreach(ClassInfo::subclassesFor(DataObject::class) as $class) {
250
			if($class === DataObject::class) {
251
				continue;
252
			}
253
			$table = $this->buildTableName($class);
254
255
			// Check for conflicts
256
			$conflict = array_search($table, $this->tableNames, true);
257
			if($conflict) {
258
				throw new LogicException(
259
					"Multiple classes (\"{$class}\", \"{$conflict}\") map to the same table: \"{$table}\""
260
				);
261
			}
262
			$this->tableNames[$class] = $table;
263
		}
264
	}
265
266
	/**
267
	 * Generate table name for a class.
268
	 *
269
	 * Note: some DB schema have a hard limit on table name length. This is not enforced by this method.
270
	 * See dev/build errors for details in case of table name violation.
271
	 *
272
	 * @param string $class
273
	 * @return string
274
	 */
275
	protected function buildTableName($class) {
276
		$table = Config::inst()->get($class, 'table_name', Config::UNINHERITED);
277
278
		// Generate default table name
279
		if(!$table) {
280
			$separator = $this->config()->get('table_namespace_separator');
281
			$table = str_replace('\\', $separator, trim($class, '\\'));
282
		}
283
284
		return $table;
285
	}
286
287
	/**
288
	 * Return the complete map of fields to specification on this object, including fixed_fields.
289
	 * "ID" will be included on every table.
290
	 *
291
	 * @param string $class Class name to query from
292
	 * @param bool $aggregated Include fields in entire hierarchy, rather than just on this table
293
	 * @return array Map of fieldname to specification, similiar to {@link DataObject::$db}.
294
	 */
295
	public function databaseFields($class, $aggregated = true) {
296
		$class = ClassInfo::class_name($class);
297
		if($class === DataObject::class) {
298
			return [];
299
		}
300
		$this->cacheDatabaseFields($class);
301
		$fields = $this->databaseFields[$class];
302
303
		if (!$aggregated) {
304
			return $fields;
305
		}
306
307
		// Recursively merge
308
		$parentFields = $this->databaseFields(get_parent_class($class));
309
		return array_merge($fields, array_diff_key($parentFields, $fields));
310
	}
311
312
	/**
313
	 * Gets a single database field.
314
	 *
315
	 * @param string $class Class name to query from
316
	 * @param string $field Field name
317
	 * @param bool $aggregated Include fields in entire hierarchy, rather than just on this table
318
	 * @return string|null Field specification, or null if not a field
319
	 */
320
	public function databaseField($class, $field, $aggregated = true) {
321
		$fields = $this->databaseFields($class, $aggregated);
322
		return isset($fields[$field]) ? $fields[$field] : null;
323
	}
324
325
	/**
326
	 * Check if the given class has a table
327
	 *
328
	 * @param string $class
329
	 * @return bool
330
	 */
331
	public function classHasTable($class) {
332
		$fields = $this->databaseFields($class, false);
333
		return !empty($fields);
334
	}
335
336
	/**
337
	 * Returns a list of all the composite if the given db field on the class is a composite field.
338
	 * Will check all applicable ancestor classes and aggregate results.
339
	 *
340
	 * Can be called directly on an object. E.g. Member::composite_fields(), or Member::composite_fields(null, true)
341
	 * to aggregate.
342
	 *
343
	 * Includes composite has_one (Polymorphic) fields
344
	 *
345
	 * @param string $class Name of class to check
346
	 * @param bool $aggregated Include fields in entire hierarchy, rather than just on this table
347
	 * @return array List of composite fields and their class spec
348
	 */
349
	public function compositeFields($class, $aggregated = true) {
350
		$class = ClassInfo::class_name($class);
351
		if($class === DataObject::class) {
352
			return [];
353
		}
354
		$this->cacheDatabaseFields($class);
355
356
		// Get fields for this class
357
		$compositeFields = $this->compositeFields[$class];
358
		if(!$aggregated) {
359
			return $compositeFields;
360
		}
361
362
		// Recursively merge
363
		$parentFields = $this->compositeFields(get_parent_class($class));
364
		return array_merge($compositeFields, array_diff_key($parentFields, $compositeFields));
365
	}
366
367
	/**
368
	 * Get a composite field for a class
369
	 *
370
	 * @param string $class Class name to query from
371
	 * @param string $field Field name
372
	 * @param bool $aggregated Include fields in entire hierarchy, rather than just on this table
373
	 * @return string|null Field specification, or null if not a field
374
	 */
375
	public function compositeField($class, $field, $aggregated = true) {
376
		$fields = $this->compositeFields($class, $aggregated);
377
		return isset($fields[$field]) ? $fields[$field] : null;
378
	}
379
380
	/**
381
	 * Cache all database and composite fields for the given class.
382
	 * Will do nothing if already cached
383
	 *
384
	 * @param string $class Class name to cache
385
	 */
386
	protected function cacheDatabaseFields($class) {
387
		// Skip if already cached
388
		if (isset($this->databaseFields[$class]) && isset($this->compositeFields[$class])) {
389
			return;
390
		}
391
		$compositeFields = array();
392
		$dbFields = array();
393
394
		// Ensure fixed fields appear at the start
395
		$fixedFields = DataObject::config()->get('fixed_fields');
396
		if(get_parent_class($class) === DataObject::class) {
397
			// Merge fixed with ClassName spec and custom db fields
398
			$dbFields = $fixedFields;
399
		} else {
400
			$dbFields['ID'] = $fixedFields['ID'];
401
		}
402
403
		// Check each DB value as either a field or composite field
404
		$db = Config::inst()->get($class, 'db', Config::UNINHERITED) ?: array();
405
		foreach($db as $fieldName => $fieldSpec) {
406
			$fieldClass = strtok($fieldSpec, '(');
407
			if(singleton($fieldClass) instanceof DBComposite) {
408
				$compositeFields[$fieldName] = $fieldSpec;
409
			} else {
410
				$dbFields[$fieldName] = $fieldSpec;
411
			}
412
		}
413
414
		// Add in all has_ones
415
		$hasOne = Config::inst()->get($class, 'has_one', Config::UNINHERITED) ?: array();
416
		foreach($hasOne as $fieldName => $hasOneClass) {
417
			if($hasOneClass === DataObject::class) {
418
				$compositeFields[$fieldName] = 'PolymorphicForeignKey';
419
			} else {
420
				$dbFields["{$fieldName}ID"] = 'ForeignKey';
421
			}
422
		}
423
424
		// Merge composite fields into DB
425
		foreach($compositeFields as $fieldName => $fieldSpec) {
426
			$fieldObj = Object::create_from_string($fieldSpec, $fieldName);
427
			$fieldObj->setTable($class);
428
			$nestedFields = $fieldObj->compositeDatabaseFields();
429
			foreach($nestedFields as $nestedName => $nestedSpec) {
430
				$dbFields["{$fieldName}{$nestedName}"] = $nestedSpec;
431
			}
432
		}
433
434
		// Prevent field-less tables with only 'ID'
435
		if(count($dbFields) < 2) {
436
			$dbFields = [];
437
		}
438
439
		// Return cached results
440
		$this->databaseFields[$class] = $dbFields;
441
		$this->compositeFields[$class] = $compositeFields;
442
	}
443
444
	/**
445
	 * Returns the table name in the class hierarchy which contains a given
446
	 * field column for a {@link DataObject}. If the field does not exist, this
447
	 * will return null.
448
	 *
449
	 * @param string $candidateClass
450
	 * @param string $fieldName
451
	 * @return string
452
	 */
453
	public function tableForField($candidateClass, $fieldName) {
454
		$class = $this->classForField($candidateClass, $fieldName);
455
		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...
456
			return $this->tableName($class);
457
		}
458
		return null;
459
	}
460
461
	/**
462
	 * Returns the class name in the class hierarchy which contains a given
463
	 * field column for a {@link DataObject}. If the field does not exist, this
464
	 * will return null.
465
	 *
466
	 * @param string $candidateClass
467
	 * @param string $fieldName
468
	 * @return string
469
	 */
470
	public function classForField($candidateClass, $fieldName)  {
471
		// normalise class name
472
		$candidateClass = ClassInfo::class_name($candidateClass);
473
		if($candidateClass === DataObject::class) {
474
			return null;
475
		}
476
477
		// Short circuit for fixed fields
478
		$fixed = DataObject::config()->get('fixed_fields');
479
		if(isset($fixed[$fieldName])) {
480
			return $this->baseDataClass($candidateClass);
481
		}
482
483
		// Find regular field
484
		while($candidateClass) {
485
			$fields = $this->databaseFields($candidateClass, false);
486
			if(isset($fields[$fieldName])) {
487
				return $candidateClass;
488
			}
489
			$candidateClass = get_parent_class($candidateClass);
490
		}
491
		return null;
492
	}
493
494
	/**
495
	 * Return information about a specific many_many component. Returns a numeric array.
496
	 * The first item in the array will be the class name of the relation.
497
	 *
498
	 * Standard many_many return type is:
499
	 *
500
	 * array(
501
	 *  <manyManyClass>,		Name of class for relation. E.g. "Categories"
502
	 * 	<classname>,			The class that relation is defined in e.g. "Product"
503
	 * 	<candidateName>,		The target class of the relation e.g. "Category"
504
	 * 	<parentField>,			The field name pointing to <classname>'s table e.g. "ProductID".
505
	 * 	<childField>,			The field name pointing to <candidatename>'s table e.g. "CategoryID".
506
	 * 	<joinTableOrRelation>	The join table between the two classes e.g. "Product_Categories".
507
	 *							If the class name is 'ManyManyThroughList' then this is the name of the
508
	 * 							has_many relation.
509
	 * )
510
	 * @param string $class Name of class to get component for
511
	 * @param string $component The component name
512
	 * @return array|null
513
	 */
514
	public function manyManyComponent($class, $component) {
515
		$classes = ClassInfo::ancestry($class);
516
		foreach($classes as $parentClass) {
517
			// Check if the component is defined in many_many on this class
518
			$manyMany = Config::inst()->get($parentClass, 'many_many', Config::UNINHERITED);
519
			if(isset($manyMany[$component])) {
520
				return $this->parseManyManyComponent($parentClass, $component, $manyMany[$component]);
521
			}
522
523
			// Check if the component is defined in belongs_many_many on this class
524
			$belongsManyMany = Config::inst()->get($parentClass, 'belongs_many_many', Config::UNINHERITED);
525
			if (!isset($belongsManyMany[$component])) {
526
				continue;
527
			}
528
529
			// Extract class and relation name from dot-notation
530
			list($childClass, $relationName)
531
				= $this->parseBelongsManyManyComponent($parentClass, $component, $belongsManyMany[$component]);
532
533
			// Build inverse relationship from other many_many, and swap parent/child
534
			list($relationClass, $childClass, $parentClass, $childField, $parentField, $joinTable)
535
				= $this->manyManyComponent($childClass, $relationName);
536
			return [$relationClass, $parentClass, $childClass, $parentField, $childField, $joinTable];
537
		}
538
		return null;
539
	}
540
541
542
543
	/**
544
	 * Parse a belongs_many_many component to extract class and relationship name
545
	 *
546
	 * @param string $parentClass Name of class
547
	 * @param string $component Name of relation on class
548
	 * @param string $specification specification for this belongs_many_many
549
	 * @return array Array with child class and relation name
550
	 */
551
	protected function parseBelongsManyManyComponent($parentClass, $component, $specification)
552
	{
553
		$childClass = $specification;
554
		$relationName = null;
555
		if (strpos($specification, '.') !== false) {
556
			list($childClass, $relationName) = explode('.', $specification, 2);
557
		}
558
559
		// We need to find the inverse component name, if not explicitly given
560
		if (!$relationName) {
561
			$relationName = $this->getManyManyInverseRelationship($childClass, $parentClass);
562
		}
563
564
		// Check valid relation found
565
		if (!$relationName) {
566
			throw new LogicException(
567
				"belongs_many_many relation {$parentClass}.{$component} points to "
568
				. "{$specification} without matching many_many"
569
			);
570
		}
571
572
		// Return relatios
573
		return array($childClass, $relationName);
574
	}
575
576
	/**
577
	 * Return the many-to-many extra fields specification for a specific component.
578
	 *
579
	 * @param string $class
580
	 * @param string $component
581
	 * @return array|null
582
	 */
583
	public function manyManyExtraFieldsForComponent($class, $component) {
584
		// Get directly declared many_many_extraFields
585
		$extraFields = Config::inst()->get($class, 'many_many_extraFields');
586
		if (isset($extraFields[$component])) {
587
			return $extraFields[$component];
588
		}
589
590
		// If not belongs_many_many then there are no components
591
		while ($class && ($class !== DataObject::class)) {
592
			$belongsManyMany = Config::inst()->get($class, 'belongs_many_many', Config::UNINHERITED);
593
			if (isset($belongsManyMany[$component])) {
594
				// Reverse relationship and find extrafields from child class
595
				list($childClass, $relationName) = $this->parseBelongsManyManyComponent($class, $component,
596
					$belongsManyMany[$component]);
597
				return $this->manyManyExtraFieldsForComponent($childClass, $relationName);
598
			}
599
			$class = get_parent_class($class);
600
		}
601
		return null;
602
	}
603
604
	/**
605
	 * Return data for a specific has_many component.
606
	 *
607
	 * @param string $class Parent class
608
	 * @param string $component
609
	 * @param bool $classOnly If this is TRUE, than any has_many relationships in the form
610
	 * "ClassName.Field" will have the field data stripped off. It defaults to TRUE.
611
	 * @return string|null
612
	 */
613
	public function hasManyComponent($class, $component, $classOnly = true) {
614
		$hasMany = (array)Config::inst()->get($class, 'has_many');
615
		if(!isset($hasMany[$component])) {
616
			return null;
617
		}
618
619
		// Remove has_one specifier if given
620
		$hasMany = $hasMany[$component];
621
		$hasManyClass = strtok($hasMany, '.');
622
623
		// Validate
624
		$this->checkRelationClass($class, $component, $hasManyClass, 'has_many');
625
		return $classOnly ? $hasManyClass : $hasMany;
626
	}
627
628
	/**
629
	 * Return data for a specific has_one component.
630
	 *
631
	 * @param string $class
632
	 * @param string $component
633
	 * @return string|null
634
	 */
635
	public function hasOneComponent($class, $component) {
636
		$hasOnes = Config::inst()->get($class, 'has_one');
637
		if(!isset($hasOnes[$component])) {
638
			return null;
639
		}
640
641
		// Validate
642
		$relationClass = $hasOnes[$component];
643
		$this->checkRelationClass($class, $component, $relationClass, 'has_one');
644
		return $relationClass;
645
	}
646
647
	/**
648
	 * Return data for a specific belongs_to component.
649
	 *
650
	 * @param string $class
651
	 * @param string $component
652
	 * @param bool $classOnly If this is TRUE, than any has_many relationships in the
653
	 * form "ClassName.Field" will have the field data stripped off. It defaults to TRUE.
654
	 * @return string|null
655
	 */
656
	public function belongsToComponent($class, $component, $classOnly = true) {
657
		$belongsTo = (array)Config::inst()->get($class, 'belongs_to');
658
		if(!isset($belongsTo[$component])) {
659
			return null;
660
		}
661
662
		// Remove has_one specifier if given
663
		$belongsTo = $belongsTo[$component];
664
		$belongsToClass = strtok($belongsTo, '.');
665
666
		// Validate
667
		$this->checkRelationClass($class, $component, $belongsToClass, 'belongs_to');
668
		return $classOnly ? $belongsToClass : $belongsTo;
669
	}
670
671
	/**
672
	 *
673
	 * @param string $parentClass Parent class name
674
	 * @param string $component ManyMany name
675
	 * @param string|array $specification Declaration of many_many relation type
676
	 * @return array
677
	 */
678
	protected function parseManyManyComponent($parentClass, $component, $specification)
679
	{
680
		// Check if this is many_many_through
681
		if (is_array($specification)) {
682
			// Validate join, parent and child classes
683
			$joinClass = $this->checkManyManyJoinClass($parentClass, $component, $specification);
684
			$parentClass = $this->checkManyManyFieldClass($parentClass, $component, $joinClass, $specification, 'from');
685
			$joinChildClass = $this->checkManyManyFieldClass($parentClass, $component, $joinClass, $specification, 'to');
686
			return [
687
				ManyManyThroughList::class,
688
				$parentClass,
689
				$joinChildClass,
690
				$specification['from'] . 'ID',
691
				$specification['to'] . 'ID',
692
				$joinClass,
693
			];
694
		}
695
696
		// Validate $specification class is valid
697
		$this->checkRelationClass($parentClass, $component, $specification, 'many_many');
698
699
		// automatic scaffolded many_many table
700
		$classTable = $this->tableName($parentClass);
701
		$parentField = "{$classTable}ID";
702
		if ($parentClass === $specification) {
703
			$childField = "ChildID";
704
		} else {
705
			$candidateTable = $this->tableName($specification);
706
			$childField = "{$candidateTable}ID";
707
		}
708
		$joinTable = "{$classTable}_{$component}";
709
		return [
710
			ManyManyList::class,
711
			$parentClass,
712
			$specification,
713
			$parentField,
714
			$childField,
715
			$joinTable,
716
		];
717
	}
718
719
	/**
720
	 * Find a many_many on the child class that points back to this many_many
721
	 *
722
	 * @param string $childClass
723
	 * @param string $parentClass
724
	 * @return string|null
725
	 */
726
	protected function getManyManyInverseRelationship($childClass, $parentClass)
727
	{
728
		$otherManyMany = Config::inst()->get($childClass, 'many_many', Config::UNINHERITED);
729
		if (!$otherManyMany) {
730
			return null;
731
		}
732
		foreach ($otherManyMany as $inverseComponentName => $nextClass) {
733
			if ($nextClass === $parentClass) {
734
				return $inverseComponentName;
735
			}
736
		}
737
		return null;
738
	}
739
740
	/**
741
	 * Tries to find the database key on another object that is used to store a
742
	 * relationship to this class. If no join field can be found it defaults to 'ParentID'.
743
	 *
744
	 * If the remote field is polymorphic then $polymorphic is set to true, and the return value
745
	 * is in the form 'Relation' instead of 'RelationID', referencing the composite DBField.
746
	 *
747
	 * @param string $class
748
	 * @param string $component Name of the relation on the current object pointing to the
749
	 * remote object.
750
	 * @param string $type the join type - either 'has_many' or 'belongs_to'
751
	 * @param boolean $polymorphic Flag set to true if the remote join field is polymorphic.
752
	 * @return string
753
	 * @throws Exception
754
	 */
755
	public function getRemoteJoinField($class, $component, $type = 'has_many', &$polymorphic = false) {
756
		// Extract relation from current object
757
		if($type === 'has_many') {
758
			$remoteClass = $this->hasManyComponent($class, $component, false);
759
		} else {
760
			$remoteClass = $this->belongsToComponent($class, $component, false);
761
		}
762
763
		if(empty($remoteClass)) {
764
			throw new Exception("Unknown $type component '$component' on class '$class'");
765
		}
766
		if(!ClassInfo::exists(strtok($remoteClass, '.'))) {
767
			throw new Exception(
768
				"Class '$remoteClass' not found, but used in $type component '$component' on class '$class'"
769
			);
770
		}
771
772
		// If presented with an explicit field name (using dot notation) then extract field name
773
		$remoteField = null;
774
		if(strpos($remoteClass, '.') !== false) {
775
			list($remoteClass, $remoteField) = explode('.', $remoteClass);
776
		}
777
778
		// Reference remote has_one to check against
779
		$remoteRelations = Config::inst()->get($remoteClass, 'has_one');
780
781
		// Without an explicit field name, attempt to match the first remote field
782
		// with the same type as the current class
783
		if(empty($remoteField)) {
784
			// look for remote has_one joins on this class or any parent classes
785
			$remoteRelationsMap = array_flip($remoteRelations);
786
			foreach(array_reverse(ClassInfo::ancestry($class)) as $class) {
787
				if(array_key_exists($class, $remoteRelationsMap)) {
788
					$remoteField = $remoteRelationsMap[$class];
789
					break;
790
				}
791
			}
792
		}
793
794
		// In case of an indeterminate remote field show an error
795
		if(empty($remoteField)) {
796
			$polymorphic = false;
797
			$message = "No has_one found on class '$remoteClass'";
798
			if($type == 'has_many') {
799
				// include a hint for has_many that is missing a has_one
800
				$message .= ", the has_many relation from '$class' to '$remoteClass'";
801
				$message .= " requires a has_one on '$remoteClass'";
802
			}
803
			throw new Exception($message);
804
		}
805
806
		// If given an explicit field name ensure the related class specifies this
807
		if(empty($remoteRelations[$remoteField])) {
808
			throw new Exception("Missing expected has_one named '$remoteField'
809
				on class '$remoteClass' referenced by $type named '$component'
810
				on class {$class}"
811
			);
812
		}
813
814
		// Inspect resulting found relation
815
		if($remoteRelations[$remoteField] === DataObject::class) {
816
			$polymorphic = true;
817
			return $remoteField; // Composite polymorphic field does not include 'ID' suffix
818
		} else {
819
			$polymorphic = false;
820
			return $remoteField . 'ID';
821
		}
822
	}
823
824
	/**
825
	 * Validate the to or from field on a has_many mapping class
826
	 *
827
	 * @param string $parentClass Name of parent class
828
	 * @param string $component Name of many_many component
829
	 * @param string $joinClass Class for the joined table
830
	 * @param array $specification Complete many_many specification
831
	 * @param string $key Name of key to check ('from' or 'to')
832
	 * @return string Class that matches the given relation
833
	 * @throws InvalidArgumentException
834
	 */
835
	protected function checkManyManyFieldClass($parentClass, $component, $joinClass, $specification, $key)
836
	{
837
		// Ensure value for this key exists
838
		if (empty($specification[$key])) {
839
			throw new InvalidArgumentException(
840
				"many_many relation {$parentClass}.{$component} has missing {$key} which "
841
				. "should be a has_one on class {$joinClass}"
842
			);
843
		}
844
845
		// Check that the field exists on the given object
846
		$relation = $specification[$key];
847
		$relationClass = $this->hasOneComponent($joinClass, $relation);
848
		if (empty($relationClass)) {
849
			throw new InvalidArgumentException(
850
				"many_many through relation {$parentClass}.{$component} {$key} references a field name "
851
				. "{$joinClass}::{$relation} which is not a has_one"
852
			);
853
		}
854
855
		// Check for polymorphic
856
		if ($relationClass === DataObject::class) {
857
			throw new InvalidArgumentException(
858
				"many_many through relation {$parentClass}.{$component} {$key} references a polymorphic field "
859
				. "{$joinClass}::{$relation} which is not supported"
860
			);
861
		}
862
863
		// Validate the join class isn't also the name of a field or relation on either side
864
		// of the relation
865
		$field = $this->fieldSpec($relationClass, $joinClass);
866
		if ($field) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $field 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...
867
			throw new InvalidArgumentException(
868
				"many_many through relation {$parentClass}.{$component} {$key} class {$relationClass} "
869
				. " cannot have a db field of the same name of the join class {$joinClass}"
870
			);
871
		}
872
873
		// Validate bad types on parent relation
874
		if ($key === 'from' && $relationClass !== $parentClass) {
875
			throw new InvalidArgumentException(
876
				"many_many through relation {$parentClass}.{$component} {$key} references a field name "
877
				. "{$joinClass}::{$relation} of type {$relationClass}; {$parentClass} expected"
878
			);
879
		}
880
		return $relationClass;
881
	}
882
883
	/**
884
	 * @param string $parentClass Name of parent class
885
	 * @param string $component Name of many_many component
886
	 * @param array $specification Complete many_many specification
887
	 * @return string Name of join class
888
	 */
889
	protected function checkManyManyJoinClass($parentClass, $component, $specification)
890
	{
891
		if (empty($specification['through'])) {
892
			throw new InvalidArgumentException(
893
				"many_many relation {$parentClass}.{$component} has missing through which should be "
894
				. "a DataObject class name to be used as a join table"
895
			);
896
		}
897
		$joinClass = $specification['through'];
898
		if (!class_exists($joinClass)) {
899
			throw new InvalidArgumentException(
900
				"many_many relation {$parentClass}.{$component} has through class \"{$joinClass}\" which does not exist"
901
			);
902
		}
903
		return $joinClass;
904
	}
905
906
	/**
907
	 * Validate a given class is valid for a relation
908
	 *
909
	 * @param string $class Parent class
910
	 * @param string $component Component name
911
	 * @param string $relationClass Candidate class to check
912
	 * @param string $type Relation type (e.g. has_one)
913
	 */
914
	protected function checkRelationClass($class, $component, $relationClass, $type)
915
	{
916
		if (!is_string($component) || is_numeric($component)) {
917
			throw new InvalidArgumentException(
918
				"{$class} has invalid {$type} relation name"
919
			);
920
		}
921
		if (!is_string($relationClass)) {
922
			throw new InvalidArgumentException(
923
				"{$type} relation {$class}.{$component} is not a class name"
924
			);
925
		}
926
		if (!class_exists($relationClass)) {
927
			throw new InvalidArgumentException(
928
				"{$type} relation {$class}.{$component} references class {$relationClass} which doesn't exist"
929
			);
930
		}
931
		// Support polymorphic has_one
932
		if ($type === 'has_one') {
933
			$valid = is_a($relationClass, DataObject::class, true);
934
		} else {
935
			$valid = is_subclass_of($relationClass, DataObject::class, true);
936
		}
937
		if (!$valid) {
938
			throw new InvalidArgumentException(
939
				"{$type} relation {$class}.{$component} references class {$relationClass} "
940
				. " which is not a subclass of " . DataObject::class
941
			);
942
		}
943
	}
944
}
945