tableNames = []; $this->databaseFields = []; $this->compositeFields = []; } /** * Get all table names * * @return array */ public function getTableNames() { $this->cacheTableNames(); return $this->tableNames; } /** * Given a DataObject class and a field on that class, determine the appropriate SQL for * selecting / filtering on in a SQL string. Note that $class must be a valid class, not an * arbitrary table. * * The result will be a standard ANSI-sql quoted string in "Table"."Column" format. * * @param string $class Class name (not a table). * @param string $field Name of field that belongs to this class (or a parent class) * @return string The SQL identifier string for the corresponding column for this field */ public function sqlColumnForField($class, $field) { $table = $this->tableForField($class, $field); if(!$table) { throw new InvalidArgumentException("\"{$field}\" is not a field on class \"{$class}\""); } return "\"{$table}\".\"{$field}\""; } /** * Get table name for the given class. * * Note that this does not confirm a table actually exists (or should exist), but returns * the name that would be used if this table did exist. * * @param string $class * @return string Returns the table name, or null if there is no table */ public function tableName($class) { $tables = $this->getTableNames(); $class = ClassInfo::class_name($class); if(isset($tables[$class])) { return $tables[$class]; } return null; } /** * Returns the root class (the first to extend from DataObject) for the * passed class. * * @param string|object $class * @return string * @throws InvalidArgumentException */ public function baseDataClass($class) { $class = ClassInfo::class_name($class); $current = $class; while ($next = get_parent_class($current)) { if ($next === DataObject::class) { return $current; } $current = $next; } throw new InvalidArgumentException("$class is not a subclass of DataObject"); } /** * Get the base table * * @param string|object $class * @return string */ public function baseDataTable($class) { return $this->tableName($this->baseDataClass($class)); } /** * Find the class for the given table * * @param string $table * @return string|null The FQN of the class, or null if not found */ public function tableClass($table) { $tables = $this->getTableNames(); $class = array_search($table, $tables, true); if($class) { return $class; } // If there is no class for this table, strip table modifiers (e.g. _Live / _versions) // from the end and re-attempt a search. if(preg_match('/^(?.+)(_[^_]+)$/i', $table, $matches)) { $table = $matches['class']; $class = array_search($table, $tables, true); if($class) { return $class; } } return null; } /** * Cache all table names if necessary */ protected function cacheTableNames() { if($this->tableNames) { return; } $this->tableNames = []; foreach(ClassInfo::subclassesFor(DataObject::class) as $class) { if($class === DataObject::class) { continue; } $table = $this->buildTableName($class); // Check for conflicts $conflict = array_search($table, $this->tableNames, true); if($conflict) { throw new LogicException( "Multiple classes (\"{$class}\", \"{$conflict}\") map to the same table: \"{$table}\"" ); } $this->tableNames[$class] = $table; } } /** * Generate table name for a class. * * Note: some DB schema have a hard limit on table name length. This is not enforced by this method. * See dev/build errors for details in case of table name violation. * * @param string $class * @return string */ protected function buildTableName($class) { $table = Config::inst()->get($class, 'table_name', Config::UNINHERITED); // Generate default table name if(!$table) { $separator = $this->config()->table_namespace_separator; $table = str_replace('\\', $separator, trim($class, '\\')); } return $table; } /** * Return the complete map of fields to specification on this object, including fixed_fields. * "ID" will be included on every table. * * @param string $class Class name to query from * @return array Map of fieldname to specification, similiar to {@link DataObject::$db}. */ public function databaseFields($class) { $class = ClassInfo::class_name($class); if($class === DataObject::class) { return []; } $this->cacheDatabaseFields($class); return $this->databaseFields[$class]; } /** * Returns a list of all the composite if the given db field on the class is a composite field. * Will check all applicable ancestor classes and aggregate results. * * Can be called directly on an object. E.g. Member::composite_fields(), or Member::composite_fields(null, true) * to aggregate. * * Includes composite has_one (Polymorphic) fields * * @param string $class Name of class to check * @param bool $aggregated Include fields in entire hierarchy, rather than just on this table * @return array List of composite fields and their class spec */ public function compositeFields($class, $aggregated = true) { $class = ClassInfo::class_name($class); if($class === DataObject::class) { return []; } $this->cacheDatabaseFields($class); // Get fields for this class $compositeFields = $this->compositeFields[$class]; if(!$aggregated) { return $compositeFields; } // Recursively merge $parentFields = $this->compositeFields(get_parent_class($class)); return array_merge($compositeFields, $parentFields); } /** * Cache all database and composite fields for the given class. * Will do nothing if already cached * * @param string $class Class name to cache */ protected function cacheDatabaseFields($class) { // Skip if already cached if (isset($this->databaseFields[$class]) && isset($this->compositeFields[$class])) { return; } $compositeFields = array(); $dbFields = array(); // Ensure fixed fields appear at the start $fixedFields = DataObject::config()->fixed_fields; if(get_parent_class($class) === DataObject::class) { // Merge fixed with ClassName spec and custom db fields $dbFields = $fixedFields; } else { $dbFields['ID'] = $fixedFields['ID']; } // Check each DB value as either a field or composite field $db = Config::inst()->get($class, 'db', Config::UNINHERITED) ?: array(); foreach($db as $fieldName => $fieldSpec) { $fieldClass = strtok($fieldSpec, '('); if(singleton($fieldClass) instanceof DBComposite) { $compositeFields[$fieldName] = $fieldSpec; } else { $dbFields[$fieldName] = $fieldSpec; } } // Add in all has_ones $hasOne = Config::inst()->get($class, 'has_one', Config::UNINHERITED) ?: array(); foreach($hasOne as $fieldName => $hasOneClass) { if($hasOneClass === DataObject::class) { $compositeFields[$fieldName] = 'PolymorphicForeignKey'; } else { $dbFields["{$fieldName}ID"] = 'ForeignKey'; } } // Merge composite fields into DB foreach($compositeFields as $fieldName => $fieldSpec) { $fieldObj = Object::create_from_string($fieldSpec, $fieldName); $fieldObj->setTable($class); $nestedFields = $fieldObj->compositeDatabaseFields(); foreach($nestedFields as $nestedName => $nestedSpec) { $dbFields["{$fieldName}{$nestedName}"] = $nestedSpec; } } // Prevent field-less tables if(count($dbFields) < 2) { $dbFields = []; } // Return cached results $this->databaseFields[$class] = $dbFields; $this->compositeFields[$class] = $compositeFields; } /** * Returns the table name in the class hierarchy which contains a given * field column for a {@link DataObject}. If the field does not exist, this * will return null. * * @param string $candidateClass * @param string $fieldName * @return string */ public function tableForField($candidateClass, $fieldName) { $class = $this->classForField($candidateClass, $fieldName); if($class) { return $this->tableName($class); } return null; } /** * Returns the class name in the class hierarchy which contains a given * field column for a {@link DataObject}. If the field does not exist, this * will return null. * * @param string $candidateClass * @param string $fieldName * @return string */ public function classForField($candidateClass, $fieldName) { // normalise class name $candidateClass = ClassInfo::class_name($candidateClass); if($candidateClass === DataObject::class) { return null; } // Short circuit for fixed fields $fixed = DataObject::config()->fixed_fields; if(isset($fixed[$fieldName])) { return $this->baseDataClass($candidateClass); } // Find regular field while($candidateClass) { $fields = $this->databaseFields($candidateClass); if(isset($fields[$fieldName])) { return $candidateClass; } $candidateClass = get_parent_class($candidateClass); } return null; } /** * Return information about a specific many_many component. Returns a numeric array. * The first item in the array will be the class name of the relation: either * RELATION_MANY_MANY or RELATION_MANY_MANY_THROUGH constant value. * * Standard many_many return type is: * * array( * , Name of class for relation * , The class that relation is defined in e.g. "Product" * , The target class of the relation e.g. "Category" * , The field name pointing to 's table e.g. "ProductID". * , The field name pointing to 's table e.g. "CategoryID". * The join table between the two classes e.g. "Product_Categories". * If the class name is 'ManyManyThroughList' then this is the name of the * has_many relation. * ) * @param string $class Name of class to get component for * @param string $component The component name * @return array|null */ public function manyManyComponent($class, $component) { $classes = ClassInfo::ancestry($class); foreach($classes as $parentClass) { // Check if the component is defined in many_many on this class $manyMany = Config::inst()->get($parentClass, 'many_many', Config::UNINHERITED); if(isset($manyMany[$component])) { return $this->parseManyManyComponent($parentClass, $component, $manyMany[$component]); } // Check if the component is defined in belongs_many_many on this class $belongsManyMany = Config::inst()->get($parentClass, 'belongs_many_many', Config::UNINHERITED); if(!isset($belongsManyMany[$component])) { continue; } // Extract class and relation name from dot-notation $childClass = $belongsManyMany[$component]; $relationName = null; if(strpos($childClass, '.') !== false) { list($childClass, $relationName) = explode('.', $childClass, 2); } // We need to find the inverse component name, if not explicitly given if (!$relationName) { $relationName = $this->getManyManyInverseRelationship($childClass, $parentClass); } // Check valid relation found if (!$relationName) { throw new LogicException("Inverse component of $childClass not found ({$class})"); } // Build inverse relationship from other many_many, and swap parent/child list($relationClass, $childClass, $parentClass, $childField, $parentField, $joinTable) = $this->parseManyManyComponent($childClass, $relationName, $parentClass); return [$relationClass, $parentClass, $childClass, $parentField, $childField, $joinTable]; } return null; } /** * Return data for a specific has_many component. * * @param string $class Parent class * @param string $component * @param bool $classOnly If this is TRUE, than any has_many relationships in the form * "ClassName.Field" will have the field data stripped off. It defaults to TRUE. * @return string|null */ public function hasManyComponent($class, $component, $classOnly = true) { $hasMany = (array)Config::inst()->get($class, 'has_many'); if(!isset($hasMany[$component])) { return null; } // Remove has_one specifier if given $hasMany = $hasMany[$component]; $hasManyClass = strtok($hasMany, '.'); // Validate $this->checkRelationClass($class, $component, $hasManyClass, 'has_many'); return $classOnly ? $hasManyClass : $hasMany; } /** * Return data for a specific has_one component. * * @param string $class * @param string $component * @return string|null */ public function hasOneComponent($class, $component) { $hasOnes = Config::inst()->get($class, 'has_one'); if(!isset($hasOnes[$component])) { return null; } // Validate $relationClass = $hasOnes[$component]; $this->checkRelationClass($class, $component, $relationClass, 'has_one'); return $relationClass; } /** * Return data for a specific belongs_to component. * * @param string $class * @param string $component * @param bool $classOnly If this is TRUE, than any has_many relationships in the * form "ClassName.Field" will have the field data stripped off. It defaults to TRUE. * @return string|null */ public function belongsToComponent($class, $component, $classOnly = true) { $belongsTo = (array)Config::inst()->get($class, 'belongs_to'); if(!isset($belongsTo[$component])) { return null; } // Remove has_one specifier if given $belongsTo = $belongsTo[$component]; $belongsToClass = strtok($belongsTo, '.'); // Validate $this->checkRelationClass($class, $component, $belongsToClass, 'belongs_to'); return $classOnly ? $belongsToClass : $belongsTo; } /** * * @param string $parentClass Parent class name * @param string $component ManyMany name * @param string|array $specification Declaration of many_many relation type * @return array */ protected function parseManyManyComponent($parentClass, $component, $specification) { // Check if this is many_many_through if (is_array($specification)) { // Validate join, parent and child classes $joinClass = $this->checkManyManyJoinClass($parentClass, $component, $specification); $parentClass = $this->checkManyManyFieldClass($parentClass, $component, $joinClass, $specification, 'from'); $joinChildClass = $this->checkManyManyFieldClass($parentClass, $component, $joinClass, $specification, 'to'); return [ ManyManyThroughList::class, $parentClass, $joinChildClass, $specification['from'] . 'ID', $specification['to'] . 'ID', $joinClass, ]; } // Validate $specification class is valid $this->checkRelationClass($parentClass, $component, $specification, 'many_many'); // automatic scaffolded many_many table $classTable = $this->tableName($parentClass); $parentField = "{$classTable}ID"; if ($parentClass === $specification) { $childField = "ChildID"; } else { $candidateTable = $this->tableName($specification); $childField = "{$candidateTable}ID"; } $joinTable = "{$classTable}_{$component}"; return [ ManyManyList::class, $parentClass, $specification, $parentField, $childField, $joinTable, ]; } /** * Find a many_many on the child class that points back to this many_many * * @param string $childClass * @param string $parentClass * @return string|null */ protected function getManyManyInverseRelationship($childClass, $parentClass) { $otherManyMany = Config::inst()->get($childClass, 'many_many', Config::UNINHERITED); if (!$otherManyMany) { return null; } foreach ($otherManyMany as $inverseComponentName => $nextClass) { if ($nextClass === $parentClass) { return $inverseComponentName; } } return null; } /** * Tries to find the database key on another object that is used to store a * relationship to this class. If no join field can be found it defaults to 'ParentID'. * * If the remote field is polymorphic then $polymorphic is set to true, and the return value * is in the form 'Relation' instead of 'RelationID', referencing the composite DBField. * * @param string $class * @param string $component Name of the relation on the current object pointing to the * remote object. * @param string $type the join type - either 'has_many' or 'belongs_to' * @param boolean $polymorphic Flag set to true if the remote join field is polymorphic. * @return string * @throws Exception */ public function getRemoteJoinField($class, $component, $type = 'has_many', &$polymorphic = false) { // Extract relation from current object if($type === 'has_many') { $remoteClass = $this->hasManyComponent($class, $component, false); } else { $remoteClass = $this->belongsToComponent($class, $component, false); } if(empty($remoteClass)) { throw new Exception("Unknown $type component '$component' on class '$class'"); } if(!ClassInfo::exists(strtok($remoteClass, '.'))) { throw new Exception( "Class '$remoteClass' not found, but used in $type component '$component' on class '$class'" ); } // If presented with an explicit field name (using dot notation) then extract field name $remoteField = null; if(strpos($remoteClass, '.') !== false) { list($remoteClass, $remoteField) = explode('.', $remoteClass); } // Reference remote has_one to check against $remoteRelations = Config::inst()->get($remoteClass, 'has_one'); // Without an explicit field name, attempt to match the first remote field // with the same type as the current class if(empty($remoteField)) { // look for remote has_one joins on this class or any parent classes $remoteRelationsMap = array_flip($remoteRelations); foreach(array_reverse(ClassInfo::ancestry($class)) as $class) { if(array_key_exists($class, $remoteRelationsMap)) { $remoteField = $remoteRelationsMap[$class]; break; } } } // In case of an indeterminate remote field show an error if(empty($remoteField)) { $polymorphic = false; $message = "No has_one found on class '$remoteClass'"; if($type == 'has_many') { // include a hint for has_many that is missing a has_one $message .= ", the has_many relation from '$class' to '$remoteClass'"; $message .= " requires a has_one on '$remoteClass'"; } throw new Exception($message); } // If given an explicit field name ensure the related class specifies this if(empty($remoteRelations[$remoteField])) { throw new Exception("Missing expected has_one named '$remoteField' on class '$remoteClass' referenced by $type named '$component' on class {$class}" ); } // Inspect resulting found relation if($remoteRelations[$remoteField] === DataObject::class) { $polymorphic = true; return $remoteField; // Composite polymorphic field does not include 'ID' suffix } else { $polymorphic = false; return $remoteField . 'ID'; } } /** * Validate the to or from field on a has_many mapping class * * @param string $parentClass Name of parent class * @param string $component Name of many_many component * @param string $joinClass Class for the joined table * @param array $specification Complete many_many specification * @param string $key Name of key to check ('from' or 'to') * @return string Class that matches the given relation * @throws InvalidArgumentException */ protected function checkManyManyFieldClass($parentClass, $component, $joinClass, $specification, $key) { // Ensure value for this key exists if (empty($specification[$key])) { throw new InvalidArgumentException( "many_many relation {$parentClass}.{$component} has missing {$key} which " . "should be a has_one on class {$joinClass}" ); } // Check that the field exists on the given object $relation = $specification[$key]; $relationClass = $this->hasOneComponent($joinClass, $relation); if (empty($relationClass)) { throw new InvalidArgumentException( "many_many through relation {$parentClass}.{$component} {$key} references a field name " . "{$joinClass}::{$relation} which is not a has_one" ); } // Check for polymorphic if ($relationClass === DataObject::class) { throw new InvalidArgumentException( "many_many through relation {$parentClass}.{$component} {$key} references a polymorphic field " . "{$joinClass}::{$relation} which is not supported" ); } // Validate bad types on parent relation if ($key === 'from' && $relationClass !== $parentClass) { throw new InvalidArgumentException( "many_many through relation {$parentClass}.{$component} {$key} references a field name " . "{$joinClass}::{$relation} of type {$relationClass}; {$parentClass} expected" ); } return $relationClass; } /** * @param string $parentClass Name of parent class * @param string $component Name of many_many component * @param array $specification Complete many_many specification * @return string Name of join class */ protected function checkManyManyJoinClass($parentClass, $component, $specification) { if (empty($specification['through'])) { throw new InvalidArgumentException( "many_many relation {$parentClass}.{$component} has missing through which should be " . "a DataObject class name to be used as a join table" ); } $joinClass = $specification['through']; if (!class_exists($joinClass)) { throw new InvalidArgumentException( "many_many relation {$parentClass}.{$component} has through class \"{$joinClass}\" which does not exist" ); } return $joinClass; } /** * Validate a given class is valid for a relation * * @param string $class Parent class * @param string $component Component name * @param string $relationClass Candidate class to check * @param string $type Relation type (e.g. has_one) */ protected function checkRelationClass($class, $component, $relationClass, $type) { if (!is_string($component) || is_numeric($component)) { throw new InvalidArgumentException( "{$class} has invalid {$type} relation name" ); } if (!is_string($relationClass)) { throw new InvalidArgumentException( "{$type} relation {$class}.{$component} is not a class name" ); } if (!class_exists($relationClass)) { throw new InvalidArgumentException( "{$type} relation {$class}.{$component} references class {$relationClass} which doesn't exist" ); } // Support polymorphic has_one if ($type === 'has_one') { $valid = is_a($relationClass, DataObject::class, true); } else { $valid = is_subclass_of($relationClass, DataObject::class, true); } if (!$valid) { throw new InvalidArgumentException( "{$type} relation {$class}.{$component} references class {$relationClass} " . " which is not a subclass of " . DataObject::class ); } } }