From 9872fbef4d3ae8f649451febb9d0e30ed6dcf843 Mon Sep 17 00:00:00 2001 From: Damian Mooyman Date: Fri, 4 Sep 2015 15:49:22 +1200 Subject: [PATCH] API Refactor CompositeDBField into an abstract class API Refactor ClassName into DBClassName API Update PolymorphicForeignKey to use new CompositeDBField and DBClassName CompositeDBField is now an interface to nested fields on an underlying dataobject, allowing field manipulation to be performed at the field and dataobject level without having to synchronise them manually. --- forms/MoneyField.php | 9 +- model/DataObject.php | 259 +++++++------- model/DataQuery.php | 1 + model/ManyManyList.php | 9 +- model/fieldtypes/CompositeDBField.php | 324 +++++++++++------- model/fieldtypes/Currency.php | 2 +- model/fieldtypes/DBClassName.php | 202 +++++++++++ model/fieldtypes/DBField.php | 31 +- model/fieldtypes/Date.php | 2 +- model/fieldtypes/Datetime.php | 2 +- model/fieldtypes/Enum.php | 90 ++++- model/fieldtypes/ForeignKey.php | 10 + model/fieldtypes/Money.php | 108 +----- model/fieldtypes/PolymorphicForeignKey.php | 159 ++------- model/fieldtypes/PrimaryKey.php | 11 +- model/fieldtypes/Time.php | 2 +- tests/model/CompositeDBFieldTest.php | 67 +++- tests/model/DBClassNameTest.php | 121 +++++++ .../model/DataObjectSchemaGenerationTest.php | 34 +- tests/model/ManyManyListTest.php | 15 + tests/model/MoneyTest.php | 6 +- tests/model/MoneyTest.yml | 12 +- view/ViewableData.php | 14 +- 23 files changed, 936 insertions(+), 554 deletions(-) create mode 100644 model/fieldtypes/DBClassName.php create mode 100644 tests/model/DBClassNameTest.php diff --git a/forms/MoneyField.php b/forms/MoneyField.php index e7f69b058..d6240a9a1 100644 --- a/forms/MoneyField.php +++ b/forms/MoneyField.php @@ -106,15 +106,18 @@ class MoneyField extends FormField { * (see @link MoneyFieldTest_CustomSetter_Object for more information) */ public function saveInto(DataObjectInterface $dataObject) { - $fieldName = $this->name; + $fieldName = $this->getName(); if($dataObject->hasMethod("set$fieldName")) { $dataObject->$fieldName = DBField::create_field('Money', array( "Currency" => $this->fieldCurrency->dataValue(), "Amount" => $this->fieldAmount->dataValue() )); } else { - $dataObject->$fieldName->setCurrency($this->fieldCurrency->dataValue()); - $dataObject->$fieldName->setAmount($this->fieldAmount->dataValue()); + $currencyField = "{$fieldName}Currency"; + $amountField = "{$fieldName}Amount"; + + $dataObject->$currencyField = $this->fieldCurrency->dataValue(); + $dataObject->$amountField = $this->fieldAmount->dataValue(); } } diff --git a/model/DataObject.php b/model/DataObject.php index 2ecc23fac..b52b4f2fa 100644 --- a/model/DataObject.php +++ b/model/DataObject.php @@ -186,8 +186,8 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity // base fields which are not defined in static $db private static $fixed_fields = array( - 'ID' => 'Int', - 'ClassName' => 'Enum', + 'ID' => 'PrimaryKey', + 'ClassName' => 'DBClassName', 'LastEdited' => 'SS_Datetime', 'Created' => 'SS_Datetime', ); @@ -229,45 +229,12 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity Config::inst()->update('DataObject', 'validation_enabled', (bool)$enable); } - /** - * @var [string] - class => ClassName field definition cache for self::database_fields - */ - private static $classname_spec_cache = array(); - /** * Clear all cached classname specs. It's necessary to clear all cached subclassed names * for any classes if a new class manifest is generated. */ public static function clear_classname_spec_cache() { - self::$classname_spec_cache = array(); - PolymorphicForeignKey::clear_classname_spec_cache(); - } - - /** - * Determines the specification for the ClassName field for the given class - * - * @param string $class - * @param boolean $queryDB Determine if the DB may be queried for additional information - * @return string Resulting ClassName spec. If $queryDB is true this will include all - * legacy types that no longer have concrete classes in PHP - */ - public static function get_classname_spec($class, $queryDB = true) { - // Check cache - if(!empty(self::$classname_spec_cache[$class])) return self::$classname_spec_cache[$class]; - - // Build known class names - $classNames = ClassInfo::subclassesFor($class); - - // Enhance with existing classes in order to prevent legacy details being lost - if($queryDB && DB::get_schema()->hasField($class, 'ClassName')) { - $existing = DB::query("SELECT DISTINCT \"ClassName\" FROM \"{$class}\"")->column(); - $classNames = array_unique(array_merge($classNames, $existing)); - } - $spec = "Enum('" . implode(', ', $classNames) . "')"; - - // Only cache full information if queried - if($queryDB) self::$classname_spec_cache[$class] = $spec; - return $spec; + DBClassName::clear_classname_cache(); } /** @@ -285,7 +252,6 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity unset($fixed['ID']); return array_merge( $fixed, - array('ClassName' => self::get_classname_spec($class, $queryDB)), self::custom_database_fields($class) ); } @@ -318,28 +284,20 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity // Remove the original fieldname, it's not an actual database column unset($fields[$fieldName]); - // Add all composite columns - $compositeFields = singleton($fieldClass)->compositeDatabaseFields(); - if($compositeFields) foreach($compositeFields as $compositeName => $spec) { + // Add all composite columns, including polymorphic relationships + $fieldObj = Object::create_from_string($fieldClass, $fieldName); + $fieldObj->setTable($class); + $compositeFields = $fieldObj->compositeDatabaseFields(); + foreach($compositeFields as $compositeName => $spec) { $fields["{$fieldName}{$compositeName}"] = $spec; } } // Add has_one relationships $hasOne = Config::inst()->get($class, 'has_one', Config::UNINHERITED); - if($hasOne) foreach(array_keys($hasOne) as $field) { - - // Check if this is a polymorphic relation, in which case the relation - // is a composite field - if($hasOne[$field] === 'DataObject') { - $relationField = DBField::create_field('PolymorphicForeignKey', null, $field); - $relationField->setTable($class); - if($compositeFields = $relationField->compositeDatabaseFields()) { - foreach($compositeFields as $compositeName => $spec) { - $fields["{$field}{$compositeName}"] = $spec; - } - } - } else { + if($hasOne) foreach($hasOne as $field => $hasOneClass) { + // exclude polymorphic relationships + if($hasOneClass !== 'DataObject') { $fields[$field . 'ID'] = 'ForeignKey'; } } @@ -385,6 +343,12 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity /** * 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. + * + * 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 */ public static function composite_fields($class, $aggregated = true) { if(!isset(DataObject::$_cache_composite_fields[$class])) self::cache_composite_fields($class); @@ -405,6 +369,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity private static function cache_composite_fields($class) { $compositeFields = array(); + // Check db $fields = Config::inst()->get($class, 'db', Config::UNINHERITED); if($fields) foreach($fields as $fieldName => $fieldClass) { if(!is_string($fieldClass)) continue; @@ -413,12 +378,20 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity $bPos = strpos($fieldClass, '('); if($bPos !== FALSE) $fieldClass = substr($fieldClass, 0, $bPos); - // Test to see if it implements CompositeDBField - if(ClassInfo::classImplements($fieldClass, 'CompositeDBField')) { + // Test to see if it extends CompositeDBField + if(is_subclass_of($fieldClass, 'CompositeDBField')) { $compositeFields[$fieldName] = $fieldClass; } } + // check has_one PolymorphicForeignKey + $hasOne = Config::inst()->get($class, 'has_one', Config::UNINHERITED); + if($hasOne) foreach($hasOne as $fieldName => $hasOneClass) { + if($hasOneClass === 'DataObject') { + $compositeFields[$fieldName] = 'PolymorphicForeignKey'; + } + } + DataObject::$_cache_composite_fields[$class] = $compositeFields; } @@ -1274,8 +1247,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity $fieldObj = DBField::create_field('Varchar', $fieldValue, $fieldName); } - // Ensure DBField is repopulated and written to the manipulation - $fieldObj->setValue($fieldValue, $this->record); + // Write to manipulation $fieldObj->writeToManipulation($manipulation[$class]); } @@ -1829,13 +1801,18 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity /** * Return data for a specific has_one component. * @param string $component + * @param string $table Out parameter of the table this has_one field belongs to * @return string|null */ - public function hasOneComponent($component) { - $hasOnes = (array)Config::inst()->get($this->class, 'has_one', Config::INHERITED); + public function hasOneComponent($component, &$table = null) { + $classes = ClassInfo::ancestry($this, true); - if(isset($hasOnes[$component])) { - return $hasOnes[$component]; + foreach(array_reverse($classes) as $class) { + $hasOnes = Config::inst()->get($class, 'has_one', Config::UNINHERITED); + if(isset($hasOnes[$component])) { + $table = $class; + return $hasOnes[$component]; + } } } @@ -1906,9 +1883,10 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity * Doesn't include any fields specified by self::$has_one. Use $this->hasOne() to get these fields * * @param string $fieldName Limit the output to a specific field name + * @param string $table Out parameter of the table this db field is set to * @return array The database fields */ - public function db($fieldName = null) { + public function db($fieldName = null, &$table = null) { $classes = ClassInfo::ancestry($this, true); // If we're looking for a specific field, we want to hit subclasses first as they may override field types @@ -1927,6 +1905,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity if($fieldName) { if(isset($dbItems[$fieldName])) { + $table = $class; return $dbItems[$fieldName]; } } else { @@ -2405,7 +2384,9 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity */ public function getField($field) { // If we already have an object in $this->record, then we should just return that - if(isset($this->record[$field]) && is_object($this->record[$field])) return $this->record[$field]; + if(isset($this->record[$field]) && is_object($this->record[$field])) { + return $this->record[$field]; + } // Do we have a field that needs to be lazy loaded? if(isset($this->record[$field.'_Lazy'])) { @@ -2413,27 +2394,12 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity $this->loadLazyFields($tableClass); } - // Otherwise, we need to determine if this is a complex field + // In case of complex fields, return the DBField object if(self::is_composite_field($this->class, $field)) { - $helper = $this->castingHelper($field); - $fieldObj = Object::create_from_string($helper, $field); - - $compositeFields = $fieldObj->compositeDatabaseFields(); - foreach ($compositeFields as $compositeName => $compositeType) { - if(isset($this->record[$field.$compositeName.'_Lazy'])) { - $tableClass = $this->record[$field.$compositeName.'_Lazy']; - $this->loadLazyFields($tableClass); - } - } - - // write value only if either the field value exists, - // or a valid record has been loaded from the database - $value = (isset($this->record[$field])) ? $this->record[$field] : null; - if($value || $this->exists()) $fieldObj->setValue($value, $this->record, false); - - $this->record[$field] = $fieldObj; - - return $this->record[$field]; + $helper = $this->db($field); + $obj = Object::create_from_string($helper, $field); + $obj->setValue(null, $this, false); + $this->record[$field] = $obj; } return isset($this->record[$field]) ? $this->record[$field] : null; @@ -2530,7 +2496,8 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity * ) * * - * @param boolean $databaseFieldsOnly Get only database fields that have changed + * @param boolean|array $databaseFieldsOnly Filter to determine which fields to return. Set to true + * to return all database fields, or an array for an explicit filter. false returns all fields. * @param int $changeLevel The strictness of what is defined as change. Defaults to strict * @return array */ @@ -2539,12 +2506,18 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity // Update the changed array with references to changed obj-fields foreach($this->record as $k => $v) { + // Prevents CompositeDBFields infinite looping on isChanged + if(is_array($databaseFieldsOnly) && !in_array($k, $databaseFieldsOnly)) { + continue; + } if(is_object($v) && method_exists($v, 'isChanged') && $v->isChanged()) { $this->changed[$k] = self::CHANGE_VALUE; } } - if($databaseFieldsOnly) { + if(is_array($databaseFieldsOnly)) { + $fields = array_intersect_key((array)$this->changed, array_flip($databaseFieldsOnly)); + } elseif($databaseFieldsOnly) { $databaseFields = $this->inheritedDatabaseFields(); $databaseFields['ID'] = true; $databaseFields['LastEdited'] = true; @@ -2584,7 +2557,8 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity * @return boolean */ public function isChanged($fieldName = null, $changeLevel = self::CHANGE_STRICT) { - $changed = $this->getChangedFields(false, $changeLevel); + $fields = $fieldName ? array($fieldName) : false; + $changed = $this->getChangedFields($fields, $changeLevel); if(!isset($fieldName)) { return !empty($changed); } @@ -2606,16 +2580,24 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity if (substr($fieldName, -2) == 'ID') { unset($this->components[substr($fieldName, 0, -2)]); } + + // If we've just lazy-loaded the column, then we need to populate the $original array + if(isset($this->record[$fieldName.'_Lazy'])) { + $tableClass = $this->record[$fieldName.'_Lazy']; + $this->loadLazyFields($tableClass); + } + // Situation 1: Passing an DBField if($val instanceof DBField) { - $val->Name = $fieldName; + $val->setName($fieldName); + $val->saveInto($this); - // If we've just lazy-loaded the column, then we need to populate the $original array by - // called getField(). Too much overhead? Could this be done by a quicker method? Maybe only - // on a call to getChanged()? - $this->getField($fieldName); - - $this->record[$fieldName] = $val; + // Situation 1a: Composite fields should remain bound in case they are + // later referenced to update the parent dataobject + if($val instanceof CompositeDBField) { + $val->bindTo($this); + $this->record[$fieldName] = $val; + } // Situation 2: Passing a literal or non-DBField object } else { // If this is a proper database field, we shouldn't be getting non-DBField objects @@ -2637,11 +2619,6 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity $this->changed[$fieldName] = self::CHANGE_VALUE; } - // If we've just lazy-loaded the column, then we need to populate the $original array by - // called getField(). Too much overhead? Could this be done by a quicker method? Maybe only - // on a call to getChanged()? - $this->getField($fieldName); - // Value is always saved back when strict check succeeds. $this->record[$fieldName] = $val; } @@ -2657,23 +2634,30 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity * * @param string $fieldName Name of the field * @param mixed $value New field value - * @return DataObject $this + * @return $this */ - public function setCastedField($fieldName, $val) { + public function setCastedField($fieldName, $value) { if(!$fieldName) { user_error("DataObject::setCastedField: Called without a fieldName", E_USER_ERROR); } - $castingHelper = $this->castingHelper($fieldName); - if($castingHelper) { - $fieldObj = Object::create_from_string($castingHelper, $fieldName); - $fieldObj->setValue($val); + $fieldObj = $this->dbObject($fieldName); + if($fieldObj) { + $fieldObj->setValue($value); $fieldObj->saveInto($this); } else { - $this->$fieldName = $val; + $this->$fieldName = $value; } return $this; } + public function castingHelper($field) { + // Allows db to act as implicit casting override + if($fieldSpec = $this->dbHelper($field)) { + return $fieldSpec; + } + return parent::castingHelper($field); + } + /** * Returns true if the given field exists in a database column on any of * the objects tables and optionally look up a dynamic getter with @@ -2938,39 +2922,55 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity * @return DBField The field as a DBField object */ public function dbObject($fieldName) { - // If we have a CompositeDBField object in $this->record, then return that + // If we have a DBField object in $this->record, then return that if(isset($this->record[$fieldName]) && is_object($this->record[$fieldName])) { return $this->record[$fieldName]; + } - // Special case for ID field - } else if($fieldName == 'ID') { - return new PrimaryKey($fieldName, $this); + // Build and populate new field otherwise + $helper = $this->dbHelper($fieldName, $table); + if($helper) { + $obj = Object::create_from_string($helper, $fieldName); + $obj->setTable($table); + $obj->setValue($this->$fieldName, $this, false); + return $obj; + } + } - // Special case for ClassName - } else if($fieldName == 'ClassName') { - $val = get_class($this); - return DBField::create_field('Varchar', $val, $fieldName); - - } else if(array_key_exists($fieldName, self::$fixed_fields)) { - return DBField::create_field(self::$fixed_fields[$fieldName], $this->$fieldName, $fieldName); + /** + * Get helper class spec for the given db field. + * + * Note that child fields of composite db fields will not be detectable via this method. + * These should be resolved indirectly by referencing 'CompositeField.Child' instead of 'CompositeFieldChild' + * in your templates + * + * @param string $fieldName + * @param string $table Out parameter of the table this has_one field belongs to + * @return DBField + */ + public function dbHelper($fieldName, &$table = null) { + // Fixed fields + if(array_key_exists($fieldName, self::$fixed_fields)) { + $table = $this->baseTable(); + return self::$fixed_fields[$fieldName]; + } // General casting information for items in $db - } else if($helper = $this->db($fieldName)) { - $obj = Object::create_from_string($helper, $fieldName); - $obj->setValue($this->$fieldName, $this->record, false); - return $obj; + if($helper = $this->db($fieldName, $table)) { + return $helper; + } // Special case for has_one relationships - } else if(preg_match('/ID$/', $fieldName) && $this->hasOneComponent(substr($fieldName,0,-2))) { - $val = $this->$fieldName; - return DBField::create_field('ForeignKey', $val, $fieldName, $this); + if(preg_match('/.+ID$/', $fieldName) && $this->hasOneComponent(substr($fieldName,0,-2), $table)) { + return 'ForeignKey'; + } // has_one for polymorphic relations do not end in ID - } else if(($type = $this->hasOneComponent($fieldName)) && ($type === 'DataObject')) { - $val = $this->$fieldName(); - return DBField::create_field('PolymorphicForeignKey', $val, $fieldName, $this); - + if(($type = $this->hasOneComponent($fieldName, $table)) && ($type === 'DataObject')) { + return 'PolymorphicForeignKey'; } + + return null; } /** @@ -3736,11 +3736,12 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity /** * Use a casting object for a field. This is a map from * field name to class name of the casting object. + * * @var array */ private static $casting = array( - "ID" => 'Int', - "ClassName" => 'Varchar', + "ID" => "PrimaryKey", + "ClassName" => "DBClassName", "LastEdited" => "SS_Datetime", "Created" => "SS_Datetime", "Title" => 'Text', diff --git a/model/DataQuery.php b/model/DataQuery.php index c72af3f62..ba4e61385 100644 --- a/model/DataQuery.php +++ b/model/DataQuery.php @@ -462,6 +462,7 @@ class DataQuery { if($compositeFields) foreach($compositeFields as $k => $v) { if((is_null($columns) || in_array($k, $columns)) && $v) { $dbO = Object::create_from_string($v, $k); + $dbO->setTable($tableClass); $dbO->addToQuery($query); } } diff --git a/model/ManyManyList.php b/model/ManyManyList.php index e06f9ed0d..9eb81a129 100644 --- a/model/ManyManyList.php +++ b/model/ManyManyList.php @@ -122,18 +122,17 @@ class ManyManyList extends RelationList { // convert joined extra fields into their composite field types. $value = array(); - foreach($composed as $subField => $subSpec) { - if(isset($row[$fieldName . $subSpec])) { - $value[$subSpec] = $row[$fieldName . $subSpec]; + foreach($composed as $subField) { + if(isset($row[$fieldName . $subField])) { + $value[$subField] = $row[$fieldName . $subField]; // don't duplicate data in the record - unset($row[$fieldName . $subSpec]); + unset($row[$fieldName . $subField]); } } $obj = Object::create_from_string($this->extraFields[$fieldName], $fieldName); $obj->setValue($value, null, false); - $add[$fieldName] = $obj; } } diff --git a/model/fieldtypes/CompositeDBField.php b/model/fieldtypes/CompositeDBField.php index dbb9e5358..9fb616115 100644 --- a/model/fieldtypes/CompositeDBField.php +++ b/model/fieldtypes/CompositeDBField.php @@ -7,96 +7,18 @@ * * Example with a combined street name and number: * -* class Street extends DBField implements CompositeDBField { -* protected $streetNumber; -* protected $streetName; -* protected $isChanged = false; -* static $composite_db = return array( +* class Street extends CompositeDBField { +* private static $composite_db = return array( * "Number" => "Int", * "Name" => "Text" * ); -* -* function requireField() { -* DB::requireField($this->tableName, "{$this->name}Number", 'Int'); -* DB::requireField($this->tableName, "{$this->name}Name", 'Text'); -* } -* -* function writeToManipulation(&$manipulation) { -* if($this->getStreetName()) { -* $manipulation['fields']["{$this->name}Name"] = $this->prepValueForDB($this->getStreetName()); -* } else { -* $manipulation['fields']["{$this->name}Name"] = DBField::create_field('Varchar', $this->getStreetName()) -* ->nullValue(); -* } -* -* if($this->getStreetNumber()) { -* $manipulation['fields']["{$this->name}Number"] = $this->prepValueForDB($this->getStreetNumber()); -* } else { -* $manipulation['fields']["{$this->name}Number"] = DBField::create_field('Int', $this->getStreetNumber()) -* ->nullValue(); -* } -* } -* -* function addToQuery(&$query) { -* parent::addToQuery($query); -* $query->setSelect("{$this->name}Number"); -* $query->setSelect("{$this->name}Name"); -* } -* -* function setValue($value, $record = null, $markChanged=true) { -* if ($value instanceof Street && $value->exists()) { -* $this->setStreetName($value->getStreetName(), $markChanged); -* $this->setStreetNumber($value->getStreetNumber(), $markChanged); -* if($markChanged) $this->isChanged = true; -* } else if($record && isset($record[$this->name . 'Name']) && isset($record[$this->name . 'Number'])) { -* if($record[$this->name . 'Name'] && $record[$this->name . 'Number']) { -* $this->setStreetName($record[$this->name . 'Name'], $markChanged); -* $this->setStreetNumber($record[$this->name . 'Number'], $markChanged); -* } -* if($markChanged) $this->isChanged = true; -* } else if (is_array($value)) { -* if (array_key_exists('Name', $value)) { -* $this->setStreetName($value['Name'], $markChanged); -* } -* if (array_key_exists('Number', $value)) { -* $this->setStreetNumber($value['Number'], $markChanged); -* } -* if($markChanged) $this->isChanged = true; -* } -* } -* -* function setStreetNumber($val, $markChanged=true) { -* $this->streetNumber = $val; -* if($markChanged) $this->isChanged = true; -* } -* -* function setStreetName($val, $markChanged=true) { -* $this->streetName = $val; -* if($markChanged) $this->isChanged = true; -* } -* -* function getStreetNumber() { -* return $this->streetNumber; -* } -* -* function getStreetName() { -* return $this->streetName; -* } -* -* function isChanged() { -* return $this->isChanged; -* } -* -* function exists() { -* return ($this->getStreetName() || $this->getStreetNumber()); -* } * } * * * @package framework * @subpackage model */ -interface CompositeDBField { +abstract class CompositeDBField extends DBField { /** * Similiar to {@link DataObject::$db}, @@ -104,48 +26,30 @@ interface CompositeDBField { * Don't include the fields "main name", * it will be prefixed in {@link requireField()}. * - * @var array $composite_db + * @config + * @var array */ - //static $composite_db; + private static $composite_db = array(); /** - * Set the value of this field in various formats. - * Used by {@link DataObject->getField()}, {@link DataObject->setCastedField()} - * {@link DataObject->dbObject()} and {@link DataObject->write()}. - * - * As this method is used both for initializing the field after construction, - * and actually changing its values, it needs a {@link $markChanged} - * parameter. - * - * @param DBField|array $value - * @param DataObject|array $record An array or object that this field is part of - * @param boolean $markChanged Indicate wether this field should be marked changed. - * Set to FALSE if you are initializing this field after construction, rather - * than setting a new value. + * Either the parent dataobject link, or a record of saved values for each field + * + * @var array|DataObject */ - public function setValue($value, $record = null, $markChanged = true); + protected $record = array(); /** - * Used in constructing the database schema. - * Add any custom properties defined in {@link $composite_db}. - * Should make one or more calls to {@link DB::requireField()}. - */ - //abstract public function requireField(); - - /** - * Add the custom internal values to an INSERT or UPDATE - * request passed through the ORM with {@link DataObject->write()}. - * Fields are added in $manipulation['fields']. Please ensure - * these fields are escaped for database insertion, as no - * further processing happens before running the query. - * Use {@link DBField->prepValueForDB()}. - * Ensure to write NULL or empty values as well to allow - * unsetting a previously set field. Use {@link DBField->nullValue()} - * for the appropriate type. + * Write all nested fields into a manipulation * * @param array $manipulation */ - public function writeToManipulation(&$manipulation); + public function writeToManipulation(&$manipulation) { + foreach($this->compositeDatabaseFields() as $field => $spec) { + // Write sub-manipulation + $fieldObject = $this->dbObject($field); + $fieldObject->writeToManipulation($manipulation); + } + } /** * Add all columns which are defined through {@link requireField()} @@ -155,30 +59,202 @@ interface CompositeDBField { * * @param SQLSelect $query */ - public function addToQuery(&$query); + public function addToQuery(&$query) { + parent::addToQuery($query); + + foreach($this->compositeDatabaseFields() as $field => $spec) { + $table = $this->getTable(); + $key = $this->getName() . $field; + if($table) { + $query->selectField("\"{$table}\".\"{$key}\""); + } else { + $query->selectField("\"{$key}\""); + } + } + } /** * Return array in the format of {@link $composite_db}. * Used by {@link DataObject->hasOwnDatabaseField()}. + * * @return array */ - public function compositeDatabaseFields(); + public function compositeDatabaseFields() { + return $this->config()->composite_db; + } + + + public function isChanged() { + // When unbound, use the local changed flag + if(! ($this->record instanceof DataObject) ) { + return $this->isChanged; + } + + // Defer to parent record + foreach($this->compositeDatabaseFields() as $field => $spec) { + $key = $this->getName() . $field; + if($this->record->isChanged($key)) { + return true; + } + } + return false; + } /** - * Determines if the field has been changed since its initialization. - * Most likely relies on an internal flag thats changed when calling - * {@link setValue()} or any other custom setters on the object. + * Composite field defaults to exists only if all fields have values * * @return boolean */ - public function isChanged(); + public function exists() { + // By default all fields + foreach($this->compositeDatabaseFields() as $field => $spec) { + $fieldObject = $this->dbObject($field); + if(!$fieldObject->exists()) { + return false; + } + } + return true; + } + + public function requireField() { + foreach($this->compositeDatabaseFields() as $field => $spec){ + $key = $this->getName() . $field; + DB::requireField($this->tableName, $key, $spec); + } + } /** - * Determines if any of the properties in this field have a value, - * meaning at least one of them is not NULL. + * Assign the given value. + * If $record is assigned to a dataobject, this field becomes a loose wrapper over + * the records on that object instead. * - * @return boolean + * @param type $value + * @param DataObject $record + * @param type $markChanged + * @return type */ - public function exists(); + public function setValue($value, $record = null, $markChanged = true) { + $this->isChanged = $markChanged; + + // When given a dataobject, bind this field to that + if($record instanceof DataObject) { + $this->bindTo($record); + $record = null; + } + + foreach($this->compositeDatabaseFields() as $field => $spec) { + // Check value + if($value instanceof CompositeDBField) { + // Check if saving from another composite field + $this->setField($field, $value->getField($field)); + + } elseif(isset($value[$field])) { + // Check if saving from an array + $this->setField($field, $value[$field]); + } + + // Load from $record + $key = $this->getName() . $field; + if(isset($record[$key])) { + $this->setField($field, $record[$key]); + } + } + } + + /** + * Bind this field to the dataobject, and set the underlying table to that of the owner + * + * @param DataObject $dataObject + */ + public function bindTo($dataObject) { + $this->record = $dataObject; + } + + public function saveInto($dataObject) { + foreach($this->compositeDatabaseFields() as $field => $spec) { + // Save into record + $key = $this->getName() . $field; + $dataObject->setField($key, $this->getField($field)); + } + } + + /** + * get value of a single composite field + * + * @param string $field + * @return mixed + */ + public function getField($field) { + // Skip invalid fields + $fields = $this->compositeDatabaseFields(); + if(!isset($fields[$field])) { + return null; + } + + // Check bound object + if($this->record instanceof DataObject) { + $key = $this->getName().$field; + return $this->record->getField($key); + } + + // Check local record + if(isset($this->record[$field])) { + return $this->record[$field]; + } + return null; + } + + public function hasField($field) { + $fields = $this->compositeDatabaseFields(); + return isset($fields[$field]); + } + + /** + * Set value of a single composite field + * + * @param string $field + * @param mixed $value + * @param bool $markChanged + */ + public function setField($field, $value, $markChanged = true) { + // Skip non-db fields + if(!$this->hasField($field)) { + return; + } + + // Set changed + if($markChanged) { + $this->isChanged = true; + } + + // Set bound object + if($this->record instanceof DataObject) { + $key = $this->getName() . $field; + return $this->record->setField($key, $value); + } + + // Set local record + $this->record[$field] = $value; + } + + /** + * Get a db object for the named field + * + * @param string $field Field name + * @return DBField|null + */ + public function dbObject($field) { + $fields = $this->compositeDatabaseFields(); + if(!isset($fields[$field])) { + return null; + } + + // Build nested field + $key = $this->getName() . $field; + $spec = $fields[$field]; + $fieldObject = Object::create_from_string($spec, $key); + $fieldObject->setValue($this->getField($field), null, false); + return $fieldObject; + } } diff --git a/model/fieldtypes/Currency.php b/model/fieldtypes/Currency.php index 0f5e11166..30e95cab7 100644 --- a/model/fieldtypes/Currency.php +++ b/model/fieldtypes/Currency.php @@ -46,7 +46,7 @@ class Currency extends Decimal { else return $val; } - public function setValue($value, $record = null) { + public function setValue($value, $record = null, $markChanged = true) { $matches = null; if(is_numeric($value)) { $this->value = $value; diff --git a/model/fieldtypes/DBClassName.php b/model/fieldtypes/DBClassName.php new file mode 100644 index 000000000..3d9f9343d --- /dev/null +++ b/model/fieldtypes/DBClassName.php @@ -0,0 +1,202 @@ +setBaseClass($baseClass); + parent::__construct($name); + } + + /** + * @return void + */ + public function requireField() { + $parts = array( + 'datatype' => 'enum', + 'enums' => $this->getEnumObsolete(), + 'character set' => 'utf8', + 'collate' => 'utf8_general_ci', + 'default' => $this->getDefault(), + 'table' => $this->getTable(), + 'arrayValue' => $this->arrayValue + ); + + $values = array( + 'type' => 'enum', + 'parts' => $parts + ); + + DB::require_field($this->getTable(), $this->getName(), $values); + } + + /** + * Get the base dataclass for the list of subclasses + * + * @return string + */ + public function getBaseClass() { + // Use explicit base class + if($this->baseClass) { + return $this->baseClass; + } + // Default to the basename of the record + if($this->record) { + return ClassInfo::baseDataClass($this->record); + } + // During dev/build only the table is assigned + $tableClass = $this->getClassNameFromTable($this->getTable()); + if($tableClass) { + return $tableClass; + } + // Fallback to global default + return 'DataObject'; + } + + /** + * Assign the base class + * + * @param string $baseClass + * @return $this + */ + public function setBaseClass($baseClass) { + $this->baseClass = $baseClass; + return $this; + } + + /** + * Given a table name, find the base data class + * + * @param string $table + * @return string|null + */ + protected function getClassNameFromTable($table) { + if(empty($table)) { + return null; + } + $class = ClassInfo::baseDataClass($table); + if($class) { + return $class; + } + // If there is no class for this table, strip table modifiers (_Live / _versions) off the end + if(preg_match('/^(?.+)(_[^_]+)$/i', $this->getTable(), $matches)) { + return $this->getClassNameFromTable($matches['class']); + } + + return null; + } + + /** + * Get list of classnames that should be selectable + * + * @return array + */ + public function getEnum() { + $classNames = ClassInfo::subclassesFor($this->getBaseClass()); + unset($classNames['DataObject']); + return $classNames; + } + + /** + * Get the list of classnames, including obsolete classes. + * + * If table or name are not set, or if it is not a valid field on the given table, + * then only known classnames are returned. + * + * Values cached in this method can be cleared via `DBClassName::clear_classname_cache();` + * + * @return array + */ + public function getEnumObsolete() { + // Without a table or field specified, we can only retrieve known classes + $table = $this->getTable(); + $name = $this->getName(); + if(empty($table) || empty($name)) { + return $this->getEnum(); + } + + // Ensure the table level cache exists + if(empty(self::$classname_cache[$table])) { + self::$classname_cache[$table] = array(); + } + + // Check existing cache + if(!empty(self::$classname_cache[$table][$name])) { + return self::$classname_cache[$table][$name]; + } + + // Get all class names + $classNames = $this->getEnum(); + if(DB::get_schema()->hasField($table, $name)) { + $existing = DB::query("SELECT DISTINCT \"{$name}\" FROM \"{$table}\"")->column(); + $classNames = array_unique(array_merge($classNames, $existing)); + } + + // Cache and return + self::$classname_cache[$table][$name] = $classNames; + return $classNames; + } + + public function setValue($value, $record = null, $markChanged = true) { + parent::setValue($value, $record, $markChanged); + + if($record instanceof DataObject) { + $this->record = $record; + } + } + + public function getDefault() { + // Check for assigned default + $default = parent::getDefault(); + if($default) { + return $default; + } + + // Fallback to first option + $enum = $this->getEnum(); + return reset($enum); + } +} diff --git a/model/fieldtypes/DBField.php b/model/fieldtypes/DBField.php index fc7f05cd9..ef162b4bd 100644 --- a/model/fieldtypes/DBField.php +++ b/model/fieldtypes/DBField.php @@ -131,14 +131,21 @@ abstract class DBField extends ViewableData { } /** - * Set the value on the field. + * Set the value of this field in various formats. + * Used by {@link DataObject->getField()}, {@link DataObject->setCastedField()} + * {@link DataObject->dbObject()} and {@link DataObject->write()}. * - * Optionally takes the whole record as an argument, to pick other values. + * As this method is used both for initializing the field after construction, + * and actually changing its values, it needs a {@link $markChanged} + * parameter. * * @param mixed $value - * @param array $record + * @param DataObject|array $record An array or object that this field is part of + * @param boolean $markChanged Indicate wether this field should be marked changed. + * Set to FALSE if you are initializing this field after construction, rather + * than setting a new value. */ - public function setValue($value, $record = null) { + public function setValue($value, $record = null, $markChanged = true) { $this->value = $value; } @@ -207,8 +214,24 @@ abstract class DBField extends ViewableData { } + /** + * Assign this DBField to a table + * + * @param string $tableName + * @return $this + */ public function setTable($tableName) { $this->tableName = $tableName; + return $this; + } + + /** + * Get the table this field belongs to, if assigned + * + * @return string|null + */ + public function getTable() { + return $this->tableName; } /** diff --git a/model/fieldtypes/Date.php b/model/fieldtypes/Date.php index 03364eb52..5003fa968 100644 --- a/model/fieldtypes/Date.php +++ b/model/fieldtypes/Date.php @@ -20,7 +20,7 @@ */ class Date extends DBField { - public function setValue($value, $record = null) { + public function setValue($value, $record = null, $markChanged = true) { if($value === false || $value === null || (is_string($value) && !strlen($value))) { // don't try to evaluate empty values with strtotime() below, as it returns "1970-01-01" when it should be // saved as NULL in database diff --git a/model/fieldtypes/Datetime.php b/model/fieldtypes/Datetime.php index ac3eb06ec..e4b7a4be5 100644 --- a/model/fieldtypes/Datetime.php +++ b/model/fieldtypes/Datetime.php @@ -25,7 +25,7 @@ */ class SS_Datetime extends Date implements TemplateGlobalProvider { - public function setValue($value, $record = null) { + public function setValue($value, $record = null, $markChanged = true) { if($value === false || $value === null || (is_string($value) && !strlen($value))) { // don't try to evaluate empty values with strtotime() below, as it returns "1970-01-01" when it should be // saved as NULL in database diff --git a/model/fieldtypes/Enum.php b/model/fieldtypes/Enum.php index 7998f5bf6..7801f77ad 100644 --- a/model/fieldtypes/Enum.php +++ b/model/fieldtypes/Enum.php @@ -9,7 +9,19 @@ */ class Enum extends StringField { - protected $enum, $default; + /** + * List of enum values + * + * @var array + */ + protected $enum = array(); + + /** + * Default value + * + * @var string|null + */ + protected $default = null; private static $default_search_filter_class = 'ExactMatchFilter'; @@ -37,16 +49,12 @@ class Enum extends StringField { */ public function __construct($name = null, $enum = NULL, $default = NULL) { if($enum) { - if(!is_array($enum)) { - $enum = preg_split("/ *, */", trim($enum)); - } - - $this->enum = $enum; + $this->setEnum($enum); // If there's a default, then if($default) { - if(in_array($default, $enum)) { - $this->default = $default; + if(in_array($default, $this->getEnum())) { + $this->setDefault($default); } else { user_error("Enum::__construct() The default value '$default' does not match any item in the" . " enumeration", E_USER_ERROR); @@ -54,7 +62,8 @@ class Enum extends StringField { // By default, set the default value to the first item } else { - $this->default = reset($enum); + $enum = $this->getEnum(); + $this->setDefault(reset($enum)); } } @@ -67,11 +76,11 @@ class Enum extends StringField { public function requireField() { $parts = array( 'datatype' => 'enum', - 'enums' => $this->enum, + 'enums' => $this->getEnum(), 'character set' => 'utf8', 'collate' => 'utf8_general_ci', - 'default' => $this->default, - 'table' => $this->tableName, + 'default' => $this->getDefault(), + 'table' => $this->getTable(), 'arrayValue' => $this->arrayValue ); @@ -80,7 +89,7 @@ class Enum extends StringField { 'parts' => $parts ); - DB::require_field($this->tableName, $this->name, $values); + DB::require_field($this->getTable(), $this->getName(), $values); } /** @@ -91,8 +100,12 @@ class Enum extends StringField { public function formField($title = null, $name = null, $hasEmpty = false, $value = "", $form = null, $emptyString = null) { - if(!$title) $title = $this->name; - if(!$name) $name = $this->name; + if(!$title) { + $title = $this->getName(); + } + if(!$name) { + $name = $this->getName(); + } $field = new DropdownField($name, $title, $this->enumValues(false), $value, $form); if($hasEmpty) { @@ -129,7 +142,50 @@ class Enum extends StringField { */ public function enumValues($hasEmpty = false) { return ($hasEmpty) - ? array_merge(array('' => ''), ArrayLib::valuekey($this->enum)) - : ArrayLib::valuekey($this->enum); + ? array_merge(array('' => ''), ArrayLib::valuekey($this->getEnum())) + : ArrayLib::valuekey($this->getEnum()); + } + + /** + * Get list of enum values + * + * @return array + */ + public function getEnum() { + return $this->enum; + } + + /** + * Set enum options + * + * @param string|array $enum + * @return $this + */ + public function setEnum($enum) { + if(!is_array($enum)) { + $enum = preg_split("/ *, */", trim($enum)); + } + $this->enum = $enum; + return $this; + } + + /** + * Get default vwalue + * + * @return string|null + */ + public function getDefault() { + return $this->default; + } + + /** + * Set default value + * + * @param string $default + * @return $this + */ + public function setDefault($default) { + $this->default = $default; + return $this; } } diff --git a/model/fieldtypes/ForeignKey.php b/model/fieldtypes/ForeignKey.php index 5850abc72..aad913e76 100644 --- a/model/fieldtypes/ForeignKey.php +++ b/model/fieldtypes/ForeignKey.php @@ -27,6 +27,9 @@ class ForeignKey extends Int { } public function scaffoldFormField($title = null, $params = null) { + if(empty($this->object)) { + return null; + } $relationName = substr($this->name,0,-2); $hasOneClass = $this->object->hasOneComponent($relationName); @@ -51,6 +54,13 @@ class ForeignKey extends Int { return $field; } + + public function setValue($value, $record = null, $markChanged = true) { + if($record instanceof DataObject) { + $this->object = $record; + } + parent::setValue($value, $record, $markChanged); + } } diff --git a/model/fieldtypes/Money.php b/model/fieldtypes/Money.php index b98569b16..ff77ed45b 100644 --- a/model/fieldtypes/Money.php +++ b/model/fieldtypes/Money.php @@ -22,22 +22,7 @@ require_once 'Zend/Currency.php'; * @package framework * @subpackage model */ -class Money extends DBField implements CompositeDBField { - - /** - * @var string $getCurrency() - */ - protected $currency; - - /** - * @var float $currencyAmount - */ - protected $amount; - - /** - * @var boolean $isChanged - */ - protected $isChanged = false; +class Money extends CompositeDBField { /** * @var string $locale @@ -69,77 +54,6 @@ class Money extends DBField implements CompositeDBField { parent::__construct($name); } - public function compositeDatabaseFields() { - return self::$composite_db; - } - - public function requireField() { - $fields = $this->compositeDatabaseFields(); - if($fields) foreach($fields as $name => $type){ - DB::require_field($this->tableName, $this->name.$name, $type); - } - } - - public function writeToManipulation(&$manipulation) { - if($this->getCurrency()) { - $manipulation['fields'][$this->name.'Currency'] = $this->prepValueForDB($this->getCurrency()); - } else { - $manipulation['fields'][$this->name.'Currency'] - = DBField::create_field('Varchar', $this->getCurrency())->nullValue(); - } - - if($this->getAmount()) { - $manipulation['fields'][$this->name.'Amount'] = $this->getAmount(); - } else { - $manipulation['fields'][$this->name.'Amount'] - = DBField::create_field('Decimal', $this->getAmount())->nullValue(); - } - } - - public function addToQuery(&$query) { - parent::addToQuery($query); - $query->selectField(sprintf('"%sAmount"', $this->name)); - $query->selectField(sprintf('"%sCurrency"', $this->name)); - } - - public function setValue($value, $record = null, $markChanged = true) { - // Convert an object to an array - if($record && $record instanceof DataObject) { - $record = $record->getQueriedDatabaseFields(); - } - - // @todo Allow resetting value to NULL through Money $value field - if ($value instanceof Money && $value->exists()) { - $this->setCurrency($value->getCurrency(), $markChanged); - $this->setAmount($value->getAmount(), $markChanged); - if($markChanged) $this->isChanged = true; - } else if($record && isset($record[$this->name . 'Amount'])) { - if($record[$this->name . 'Amount']) { - if(!empty($record[$this->name . 'Currency'])) { - $this->setCurrency($record[$this->name . 'Currency'], $markChanged); - } else if($currency = (string)$this->config()->default_currency) { - $this->setCurrency($currency, $markChanged); - } - - $this->setAmount($record[$this->name . 'Amount'], $markChanged); - } else { - $this->value = $this->nullValue(); - } - if($markChanged) $this->isChanged = true; - } else if (is_array($value)) { - if (array_key_exists('Currency', $value)) { - $this->setCurrency($value['Currency'], $markChanged); - } - if (array_key_exists('Amount', $value)) { - $this->setAmount($value['Amount'], $markChanged); - } - if($markChanged) $this->isChanged = true; - } else { - // @todo Allow to reset a money value by passing in NULL - //user_error('Invalid value in Money->setValue()', E_USER_ERROR); - } - } - /** * @return string */ @@ -173,32 +87,28 @@ class Money extends DBField implements CompositeDBField { * @return string */ public function getCurrency() { - return $this->currency; + return $this->getField('Currency'); } /** * @param string */ public function setCurrency($currency, $markChanged = true) { - $this->currency = $currency; - if($markChanged) $this->isChanged = true; + $this->setField('Currency', $currency, $markChanged); } /** - * @todo Return casted Float DBField? - * * @return float */ public function getAmount() { - return $this->amount; + return $this->getField('Amount'); } /** * @param float $amount */ public function setAmount($amount, $markChanged = true) { - $this->amount = (float)$amount; - if($markChanged) $this->isChanged = true; + $this->setField('Amount', (float)$amount, $markChanged); } /** @@ -216,10 +126,6 @@ class Money extends DBField implements CompositeDBField { return (!empty($a) && is_numeric($a)); } - public function isChanged() { - return $this->isChanged; - } - /** * @param string $locale */ @@ -259,7 +165,7 @@ class Money extends DBField implements CompositeDBField { /** * @return string */ - public function getName($currency = null, $locale = null) { + public function getCurrencyName($currency = null, $locale = null) { if($locale === null) $locale = $this->getLocale(); if($currency === null) $currency = $this->getCurrency(); @@ -290,7 +196,7 @@ class Money extends DBField implements CompositeDBField { * @return FormField */ public function scaffoldFormField($title = null) { - $field = new MoneyField($this->name); + $field = new MoneyField($this->getName()); $field->setAllowedCurrencies($this->getAllowedCurrencies()); $field->setLocale($this->getLocale()); diff --git a/model/fieldtypes/PolymorphicForeignKey.php b/model/fieldtypes/PolymorphicForeignKey.php index e6d1898fa..07f68c6df 100644 --- a/model/fieldtypes/PolymorphicForeignKey.php +++ b/model/fieldtypes/PolymorphicForeignKey.php @@ -6,34 +6,12 @@ * @package framework * @subpackage model */ -class PolymorphicForeignKey extends ForeignKey implements CompositeDBField { +class PolymorphicForeignKey extends CompositeDBField { - /** - * @var boolean $isChanged - */ - protected $isChanged = false; - - /** - * Value of relation class - * - * @var string - */ - protected $classValue = null; - - /** - * Field definition cache for compositeDatabaseFields - * - * @var string - */ - protected static $classname_spec_cache = array(); - - /** - * Clear all cached classname specs. It's necessary to clear all cached subclassed names - * for any classes if a new class manifest is generated. - */ - public static function clear_classname_spec_cache() { - self::$classname_spec_cache = array(); - } + private static $composite_db = array( + 'ID' => 'Int', + 'Class' => 'DBClassName("DataObject")' + ); public function scaffoldFormField($title = null, $params = null) { // Opt-out of form field generation - Scaffolding should be performed on @@ -42,55 +20,23 @@ class PolymorphicForeignKey extends ForeignKey implements CompositeDBField { return null; } - public function requireField() { - $fields = $this->compositeDatabaseFields(); - if($fields) foreach($fields as $name => $type){ - DB::requireField($this->tableName, $this->name.$name, $type); - } - } - - public function writeToManipulation(&$manipulation) { - - // Write ID, checking that the value is valid - $manipulation['fields'][$this->name . 'ID'] = $this->exists() - ? $this->prepValueForDB($this->getIDValue()) - : $this->nullValue(); - - // Write class - $classObject = DBField::create_field('Enum', $this->getClassValue(), $this->name . 'Class'); - $classObject->writeToManipulation($manipulation); - } - - public function addToQuery(&$query) { - parent::addToQuery($query); - $query->selectField( - "\"{$this->tableName}\".\"{$this->name}ID\"", - "{$this->name}ID" - ); - $query->selectField( - "\"{$this->tableName}\".\"{$this->name}Class\"", - "{$this->name}Class" - ); - } - /** * Get the value of the "Class" this key points to * * @return string Name of a subclass of DataObject */ public function getClassValue() { - return $this->classValue; + return $this->getField('Class'); } /** * Set the value of the "Class" this key points to * - * @param string $class Name of a subclass of DataObject + * @param string $value Name of a subclass of DataObject * @param boolean $markChanged Mark this field as changed? */ - public function setClassValue($class, $markChanged = true) { - $this->classValue = $class; - if($markChanged) $this->isChanged = true; + public function setClassValue($value, $markChanged = true) { + $this->setField('Class', $value, $markChanged); } /** @@ -99,95 +45,36 @@ class PolymorphicForeignKey extends ForeignKey implements CompositeDBField { * @return integer */ public function getIDValue() { - return parent::getValue(); + return $this->getField('ID'); } /** * Sets the value of the "ID" this key points to * - * @param integer $id + * @param integer $value * @param boolean $markChanged Mark this field as changed? */ - public function setIDValue($id, $markChanged = true) { - parent::setValue($id); - if($markChanged) $this->isChanged = true; + public function setIDValue($value, $markChanged = true) { + $this->setField('ID', $value, $markChanged); } public function setValue($value, $record = null, $markChanged = true) { - $idField = "{$this->name}ID"; - $classField = "{$this->name}Class"; - - // Check if an object is assigned directly + // Map dataobject value to array if($value instanceof DataObject) { - $record = array( - $idField => $value->ID, - $classField => $value->class + $value = array( + 'ID' => $value->ID, + 'Class' => $value->class ); } - - // Convert an object to an array - if($record instanceof DataObject) { - $record = $record->getQueriedDatabaseFields(); - } - - // Use $value array if record is missing - if(empty($record) && is_array($value)) { - $record = $value; - } - - // Inspect presented values - if(isset($record[$idField]) && isset($record[$classField])) { - if(empty($record[$idField]) || empty($record[$classField])) { - $this->setIDValue($this->nullValue(), $markChanged); - $this->setClassValue('', $markChanged); - } else { - $this->setClassValue($record[$classField], $markChanged); - $this->setIDValue($record[$idField], $markChanged); - } - } + + parent::setValue($value, $record, $markChanged); } public function getValue() { - if($this->exists()) { - return DataObject::get_by_id($this->getClassValue(), $this->getIDValue()); + $id = $this->getIDValue(); + $class = $this->getClassValue(); + if($id && $class && is_subclass_of($class, 'DataObject')) { + return DataObject::get_by_id($class, $id); } } - - public function compositeDatabaseFields() { - - // Ensure the table level cache exists - if(empty(self::$classname_spec_cache[$this->tableName])) { - self::$classname_spec_cache[$this->tableName] = array(); - } - - // Ensure the field level cache exists - if(empty(self::$classname_spec_cache[$this->tableName][$this->name])) { - - // Get all class names - $classNames = ClassInfo::subclassesFor('DataObject'); - unset($classNames['DataObject']); - - $schema = DB::get_schema(); - if($schema->hasField($this->tableName, "{$this->name}Class")) { - $existing = DB::query("SELECT DISTINCT \"{$this->name}Class\" FROM \"{$this->tableName}\"")->column(); - $classNames = array_unique(array_merge($classNames, $existing)); - } - - self::$classname_spec_cache[$this->tableName][$this->name] - = "Enum(array('" . implode("', '", array_filter($classNames)) . "'))"; - } - - return array( - 'ID' => 'Int', - 'Class' => self::$classname_spec_cache[$this->tableName][$this->name] - ); - } - - public function isChanged() { - return $this->isChanged; - } - - public function exists() { - return $this->getClassValue() && $this->getIDValue(); - } } diff --git a/model/fieldtypes/PrimaryKey.php b/model/fieldtypes/PrimaryKey.php index a741cf14c..854cefcaf 100644 --- a/model/fieldtypes/PrimaryKey.php +++ b/model/fieldtypes/PrimaryKey.php @@ -19,7 +19,7 @@ class PrimaryKey extends Int { * @param string $name * @param DataOject $object The object that this is primary key for (should have a relation with $name) */ - public function __construct($name = null, $object) { + public function __construct($name, $object = null) { $this->object = $object; parent::__construct($name); } @@ -31,5 +31,12 @@ class PrimaryKey extends Int { $field->setEmptyString(' '); return $field; } -} + public function setValue($value, $record = null, $markChanged = true) { + parent::setValue($value, $record, $markChanged); + + if($record instanceof DataObject) { + $this->object = $record; + } + } +} diff --git a/model/fieldtypes/Time.php b/model/fieldtypes/Time.php index 3b7141495..8d219b533 100644 --- a/model/fieldtypes/Time.php +++ b/model/fieldtypes/Time.php @@ -16,7 +16,7 @@ */ class Time extends DBField { - public function setValue($value, $record = null) { + public function setValue($value, $record = null, $markChanged = true) { if($value) { if(preg_match( '/(\d{1,2})[:.](\d{2})([a|A|p|P|][m|M])/', $value, $match )) $this->TwelveHour( $match ); else $this->value = date('H:i:s', strtotime($value)); diff --git a/tests/model/CompositeDBFieldTest.php b/tests/model/CompositeDBFieldTest.php index ffe6d2fb8..db79954ad 100644 --- a/tests/model/CompositeDBFieldTest.php +++ b/tests/model/CompositeDBFieldTest.php @@ -16,6 +16,15 @@ class CompositeDBFieldTest extends SapphireTest { $this->assertTrue($obj->hasDatabaseField('MyMoneyAmount')); $this->assertTrue($obj->hasDatabaseField('MyMoneyCurrency')); $this->assertFalse($obj->hasDatabaseField('MyMoney')); + + // Check that nested fields are exposed properly + $this->assertTrue($obj->dbObject('MyMoney')->hasField('Amount')); + $this->assertTrue($obj->dbObject('MyMoney')->hasField('Currency')); + + // Not strictly correct + $this->assertFalse($obj->dbObject('MyMoney')->hasField('MyMoneyAmount')); + $this->assertFalse($obj->dbObject('MyMoney')->hasField('MyMoneyCurrency')); + $this->assertFalse($obj->dbObject('MyMoney')->hasField('MyMoney')); } /** @@ -24,16 +33,64 @@ class CompositeDBFieldTest extends SapphireTest { public function testCompositeFieldMetaDataFunctions() { $this->assertEquals('Money', DataObject::is_composite_field('CompositeDBFieldTest_DataObject', 'MyMoney')); $this->assertNull(DataObject::is_composite_field('CompositeDBFieldTest_DataObject', 'Title')); - $this->assertEquals(array('MyMoney' => 'Money'), - DataObject::composite_fields('CompositeDBFieldTest_DataObject')); + $this->assertEquals( + array( + 'MyMoney' => 'Money', + 'OverriddenMoney' => 'Money' + ), + DataObject::composite_fields('CompositeDBFieldTest_DataObject') + ); $this->assertEquals('Money', DataObject::is_composite_field('SubclassedDBFieldObject', 'MyMoney')); $this->assertEquals('Money', DataObject::is_composite_field('SubclassedDBFieldObject', 'OtherMoney')); $this->assertNull(DataObject::is_composite_field('SubclassedDBFieldObject', 'Title')); $this->assertNull(DataObject::is_composite_field('SubclassedDBFieldObject', 'OtherField')); - $this->assertEquals(array('MyMoney' => 'Money', 'OtherMoney' => 'Money'), - DataObject::composite_fields('SubclassedDBFieldObject')); + $this->assertEquals( + array( + 'MyMoney' => 'Money', + 'OtherMoney' => 'Money', + 'OverriddenMoney' => 'Money', + ), + DataObject::composite_fields('SubclassedDBFieldObject') + ); + } + + /** + * Tests that changes to the fields affect the underlying dataobject, and vice versa + */ + public function testFieldBinding() { + $object = new CompositeDBFieldTest_DataObject(); + $object->MyMoney->Currency = 'NZD'; + $object->MyMoney->Amount = 100.0; + $this->assertEquals('NZD', $object->MyMoneyCurrency); + $this->assertEquals(100.0, $object->MyMoneyAmount); + $object->write(); + + $object2 = CompositeDBFieldTest_DataObject::get()->byID($object->ID); + $this->assertEquals('NZD', $object2->MyMoney->Currency); + $this->assertEquals(100.0, $object2->MyMoney->Amount); + + $object2->MyMoneyCurrency = 'USD'; + $this->assertEquals('USD', $object2->MyMoney->Currency); + + $object2->MyMoney->setValue(array('Currency' => 'EUR', 'Amount' => 200.0)); + $this->assertEquals('EUR', $object2->MyMoneyCurrency); + $this->assertEquals(200.0, $object2->MyMoneyAmount); + } + + /** + * Ensures that composite fields are assigned to the correct tables + */ + public function testInheritedTables() { + $object1 = new CompositeDBFieldTest_DataObject(); + $object2 = new SubclassedDBFieldObject(); + + $this->assertEquals('CompositeDBFieldTest_DataObject', $object1->dbObject('MyMoney')->getTable()); + $this->assertEquals('CompositeDBFieldTest_DataObject', $object1->dbObject('OverriddenMoney')->getTable()); + $this->assertEquals('CompositeDBFieldTest_DataObject', $object2->dbObject('MyMoney')->getTable()); + $this->assertEquals('SubclassedDBFieldObject', $object2->dbObject('OtherMoney')->getTable()); + $this->assertEquals('SubclassedDBFieldObject', $object2->dbObject('OverriddenMoney')->getTable()); } } @@ -41,6 +98,7 @@ class CompositeDBFieldTest_DataObject extends DataObject implements TestOnly { private static $db = array( 'Title' => 'Text', 'MyMoney' => 'Money', + 'OverriddenMoney' => 'Money' ); } @@ -48,5 +106,6 @@ class SubclassedDBFieldObject extends CompositeDBFieldTest_DataObject { private static $db = array( 'OtherField' => 'Text', 'OtherMoney' => 'Money', + 'OverriddenMoney' => 'Money' ); } diff --git a/tests/model/DBClassNameTest.php b/tests/model/DBClassNameTest.php new file mode 100644 index 000000000..c620e4d49 --- /dev/null +++ b/tests/model/DBClassNameTest.php @@ -0,0 +1,121 @@ +dbObject('DefaultClass'); + $anyClass = $object->dbObject('AnyClass'); + $childClass = $object->dbObject('ChildClass'); + $leafClass = $object->dbObject('LeafClass'); + + // Object 2 fields + $object2 = new DBClassNameTest_ObjectSubClass(); + $midDefault = $object2->dbObject('MidClassDefault'); + $midClass = $object2->dbObject('MidClass'); + + // Default fields always default to children of base class (even if put in a subclass) + $mainSubclasses = array ( + 'DBClassNameTest_Object' => 'DBClassNameTest_Object', + 'DBClassNameTest_ObjectSubClass' => 'DBClassNameTest_ObjectSubClass', + 'DBClassNameTest_ObjectSubSubClass' => 'DBClassNameTest_ObjectSubSubClass', + ); + $this->assertEquals($mainSubclasses, $defaultClass->getEnumObsolete()); + $this->assertEquals($mainSubclasses, $midDefault->getEnumObsolete()); + + // Unbound classes detect any + $anyClasses = $anyClass->getEnumObsolete(); + $this->assertContains('DBClassNameTest_OtherClass', $anyClasses); + $this->assertContains('DBClassNameTest_Object', $anyClasses); + $this->assertContains('DBClassNameTest_ObjectSubClass', $anyClasses); + $this->assertContains('DBClassNameTest_ObjectSubSubClass', $anyClasses); + + // Classes bound to the middle of a tree + $midSubClasses = $mainSubclasses = array ( + 'DBClassNameTest_ObjectSubClass' => 'DBClassNameTest_ObjectSubClass', + 'DBClassNameTest_ObjectSubSubClass' => 'DBClassNameTest_ObjectSubSubClass', + ); + $this->assertEquals($midSubClasses, $childClass->getEnumObsolete()); + $this->assertEquals($midSubClasses, $midClass->getEnumObsolete()); + + // Leaf clasess contain only exactly one node + $this->assertEquals( + array('DBClassNameTest_ObjectSubSubClass' => 'DBClassNameTest_ObjectSubSubClass',), + $leafClass->getEnumObsolete() + ); + } + + /** + * Test that the base class can be detected under various circumstances + */ + public function testBaseClassDetection() { + // Explicit DataObject + $field1 = new DBClassName('MyClass', 'DataObject'); + $this->assertEquals('DataObject', $field1->getBaseClass()); + + // Explicit base class + $field2 = new DBClassName('MyClass', 'DBClassNameTest_Object'); + $this->assertEquals('DBClassNameTest_Object', $field2->getBaseClass()); + + // Explicit subclass + $field3 = new DBClassName('MyClass'); + $field3->setValue(null, new DBClassNameTest_ObjectSubClass()); + $this->assertEquals('DBClassNameTest_Object', $field3->getBaseClass()); + + // Implicit table + $field4 = new DBClassName('MyClass'); + $field4->setTable('DBClassNameTest_ObjectSubClass_versions'); + $this->assertEquals('DBClassNameTest_Object', $field4->getBaseClass()); + + // Missing + $field5 = new DBClassName('MyClass'); + $this->assertEquals('DataObject', $field5->getBaseClass()); + + // Invalid class + $field6 = new DBClassName('MyClass'); + $field6->setTable('InvalidTable'); + $this->assertEquals('DataObject', $field6->getBaseClass()); + } +} + +class DBClassNameTest_Object extends DataObject implements TestOnly { + + private static $extensions = array( + 'Versioned' + ); + + private static $db = array( + 'DefaultClass' => 'DBClassName', + 'AnyClass' => 'DBClassName("DataObject")', + 'ChildClass' => 'DBClassName("DBClassNameTest_ObjectSubClass")', + 'LeafClass' => 'DBClassName("DBClassNameTest_ObjectSubSubClass")' + ); +} + +class DBClassNameTest_ObjectSubClass extends DBClassNameTest_Object { + private static $db = array( + 'MidClassDefault' => 'DBClassName', + 'MidClass' => 'DBClassName("DBClassNameTest_ObjectSubclass")' + ); + +} + +class DBClassNameTest_ObjectSubSubClass extends DBClassNameTest_ObjectSubclass { +} + +class DBClassNameTest_OtherClass extends DataObject implements TestOnly { + private static $db = array( + 'Title' => 'Varchar' + ); +} \ No newline at end of file diff --git a/tests/model/DataObjectSchemaGenerationTest.php b/tests/model/DataObjectSchemaGenerationTest.php index 7c4b80dfe..519c4c273 100644 --- a/tests/model/DataObjectSchemaGenerationTest.php +++ b/tests/model/DataObjectSchemaGenerationTest.php @@ -130,20 +130,30 @@ class DataObjectSchemaGenerationTest extends SapphireTest { // Test with blank entries DataObject::clear_classname_spec_cache(); + $do1 = new DataObjectSchemaGenerationTest_DO(); $fields = DataObject::database_fields('DataObjectSchemaGenerationTest_DO'); + $this->assertEquals("DBClassName", $fields['ClassName']); $this->assertEquals( - "Enum('DataObjectSchemaGenerationTest_DO, DataObjectSchemaGenerationTest_IndexDO')", - $fields['ClassName'] + array( + 'DataObjectSchemaGenerationTest_DO' => 'DataObjectSchemaGenerationTest_DO', + 'DataObjectSchemaGenerationTest_IndexDO' => 'DataObjectSchemaGenerationTest_IndexDO' + ), + $do1->dbObject('ClassName')->getEnum() ); + // Test with instance of subclass $item1 = new DataObjectSchemaGenerationTest_IndexDO(); $item1->write(); DataObject::clear_classname_spec_cache(); $fields = DataObject::database_fields('DataObjectSchemaGenerationTest_DO'); + $this->assertEquals("DBClassName", $fields['ClassName']); $this->assertEquals( - "Enum('DataObjectSchemaGenerationTest_DO, DataObjectSchemaGenerationTest_IndexDO')", - $fields['ClassName'] + array( + 'DataObjectSchemaGenerationTest_DO' => 'DataObjectSchemaGenerationTest_DO', + 'DataObjectSchemaGenerationTest_IndexDO' => 'DataObjectSchemaGenerationTest_IndexDO' + ), + $item1->dbObject('ClassName')->getEnum() ); $item1->delete(); @@ -152,9 +162,13 @@ class DataObjectSchemaGenerationTest extends SapphireTest { $item2->write(); DataObject::clear_classname_spec_cache(); $fields = DataObject::database_fields('DataObjectSchemaGenerationTest_DO'); + $this->assertEquals("DBClassName", $fields['ClassName']); $this->assertEquals( - "Enum('DataObjectSchemaGenerationTest_DO, DataObjectSchemaGenerationTest_IndexDO')", - $fields['ClassName'] + array( + 'DataObjectSchemaGenerationTest_DO' => 'DataObjectSchemaGenerationTest_DO', + 'DataObjectSchemaGenerationTest_IndexDO' => 'DataObjectSchemaGenerationTest_IndexDO' + ), + $item2->dbObject('ClassName')->getEnum() ); $item2->delete(); @@ -165,9 +179,13 @@ class DataObjectSchemaGenerationTest extends SapphireTest { $item2->write(); DataObject::clear_classname_spec_cache(); $fields = DataObject::database_fields('DataObjectSchemaGenerationTest_DO'); + $this->assertEquals("DBClassName", $fields['ClassName']); $this->assertEquals( - "Enum('DataObjectSchemaGenerationTest_DO, DataObjectSchemaGenerationTest_IndexDO')", - $fields['ClassName'] + array( + 'DataObjectSchemaGenerationTest_DO' => 'DataObjectSchemaGenerationTest_DO', + 'DataObjectSchemaGenerationTest_IndexDO' => 'DataObjectSchemaGenerationTest_IndexDO' + ), + $item1->dbObject('ClassName')->getEnum() ); $item1->delete(); $item2->delete(); diff --git a/tests/model/ManyManyListTest.php b/tests/model/ManyManyListTest.php index 1e18f844e..8d2fcb0cc 100644 --- a/tests/model/ManyManyListTest.php +++ b/tests/model/ManyManyListTest.php @@ -10,8 +10,23 @@ class ManyManyListTest extends SapphireTest { protected $extraDataObjects = array( 'DataObjectTest_Team', + 'DataObjectTest_Fixture', 'DataObjectTest_SubTeam', + 'OtherSubclassWithSameField', + 'DataObjectTest_FieldlessTable', + 'DataObjectTest_FieldlessSubTable', + 'DataObjectTest_ValidatedObject', 'DataObjectTest_Player', + 'DataObjectTest_TeamComment', + 'DataObjectTest_EquipmentCompany', + 'DataObjectTest_SubEquipmentCompany', + 'DataObjectTest\NamespacedClass', + 'DataObjectTest\RelationClass', + 'DataObjectTest_ExtendedTeamComment', + 'DataObjectTest_Company', + 'DataObjectTest_Staff', + 'DataObjectTest_CEO', + 'DataObjectTest_Fan', 'ManyManyListTest_ExtraFields' ); diff --git a/tests/model/MoneyTest.php b/tests/model/MoneyTest.php index 58a81c2d2..40d27c77d 100644 --- a/tests/model/MoneyTest.php +++ b/tests/model/MoneyTest.php @@ -145,11 +145,11 @@ class MoneyTest extends SapphireTest { )); $m->setLocale('ar_EG'); - $this->assertSame('Estnische Krone', $m->getName('EEK','de_AT')); - $this->assertSame('يورو', $m->getName()); + $this->assertSame('Estnische Krone', $m->getCurrencyName('EEK','de_AT')); + $this->assertSame('يورو', $m->getCurrencyName()); try { - $m->getName('EGP', 'xy_XY'); + $m->getCurrencyName('EGP', 'xy_XY'); $this->setExpectedException("Exception"); } catch(Exception $e) { } diff --git a/tests/model/MoneyTest.yml b/tests/model/MoneyTest.yml index 816f4f9e3..8937eb307 100644 --- a/tests/model/MoneyTest.yml +++ b/tests/model/MoneyTest.yml @@ -1,8 +1,8 @@ MoneyTest_DataObject: - test1: - MyMoneyCurrency: EUR - MyMoneyAmount: 1.23 + test1: + MyMoneyCurrency: EUR + MyMoneyAmount: 1.23 MoneyTest_SubClass: - test2: - MyOtherMoneyCurrency: GBP - MyOtherMoneyAmount: 2.46 \ No newline at end of file + test2: + MyOtherMoneyCurrency: GBP + MyOtherMoneyAmount: 2.46 \ No newline at end of file diff --git a/view/ViewableData.php b/view/ViewableData.php index c70023561..09d52aebb 100644 --- a/view/ViewableData.php +++ b/view/ViewableData.php @@ -263,17 +263,15 @@ class ViewableData extends Object implements IteratorAggregate { * on this object. * * @param string $field - * @return string + * @return string Casting helper */ public function castingHelper($field) { - if($this->hasMethod('db') && $fieldSpec = $this->db($field)) { - return $fieldSpec; + $specs = $this->config()->casting; + if(isset($specs[$field])) { + return $specs[$field]; + } elseif($this->failover) { + return $this->failover->castingHelper($field); } - - $specs = Config::inst()->get(get_class($this), 'casting'); - if(isset($specs[$field])) return $specs[$field]; - - if($this->failover) return $this->failover->castingHelper($field); } /**