Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP
Fetching contributors…

Cannot retrieve contributors at this time

3461 lines (3028 sloc) 117.389 kB
<?php
/**
* A single database record & abstract class for the data-access-model.
*
* <h2>Extensions</h2>
*
* See {@link Extension} and {@link DataExtension}.
*
* <h2>Permission Control</h2>
*
* Object-level access control by {@link Permission}. Permission codes are arbitrary
* strings which can be selected on a group-by-group basis.
*
* <code>
* class Article extends DataObject implements PermissionProvider {
* static $api_access = true;
*
* public function canView($member = false) {
* return Permission::check('ARTICLE_VIEW');
* }
* public function canEdit($member = false) {
* return Permission::check('ARTICLE_EDIT');
* }
* public function canDelete() {
* return Permission::check('ARTICLE_DELETE');
* }
* public function canCreate() {
* return Permission::check('ARTICLE_CREATE');
* }
* public function providePermissions() {
* return array(
* 'ARTICLE_VIEW' => 'Read an article object',
* 'ARTICLE_EDIT' => 'Edit an article object',
* 'ARTICLE_DELETE' => 'Delete an article object',
* 'ARTICLE_CREATE' => 'Create an article object',
* );
* }
* }
* </code>
*
* Object-level access control by {@link Group} membership:
* <code>
* class Article extends DataObject {
* static $api_access = true;
*
* public function canView($member = false) {
* if(!$member) $member = Member::currentUser();
* return $member->inGroup('Subscribers');
* }
* public function canEdit($member = false) {
* if(!$member) $member = Member::currentUser();
* return $member->inGroup('Editors');
* }
*
* // ...
* }
* </code>
*
* If any public method on this class is prefixed with an underscore,
* the results are cached in memory through {@link cachedCall()}.
*
*
* @todo Add instance specific removeExtension() which undos loadExtraStatics()
* and defineMethods()
*
* @package framework
* @subpackage model
*/
class DataObject extends ViewableData implements DataObjectInterface, i18nEntityProvider {
/**
* Human-readable singular name.
* @var string
*/
public static $singular_name = null;
/**
* Human-readable pluaral name
* @var string
*/
public static $plural_name = null;
/**
* Allow API access to this object?
* @todo Define the options that can be set here
*/
public static $api_access = false;
/**
* True if this DataObject has been destroyed.
* @var boolean
*/
public $destroyed = false;
/**
* The DataModel from this this object comes
*/
protected $model;
/**
* Data stored in this objects database record. An array indexed by fieldname.
*
* Use {@link toMap()} if you want an array representation
* of this object, as the $record array might contain lazy loaded field aliases.
*
* @var array
*/
protected $record;
/**
* An array indexed by fieldname, true if the field has been changed.
* Use {@link getChangedFields()} and {@link isChanged()} to inspect
* the changed state.
*
* @var array
*/
private $changed;
/**
* The database record (in the same format as $record), before
* any changes.
* @var array
*/
protected $original;
/**
* Used by onBeforeDelete() to ensure child classes call parent::onBeforeDelete()
* @var boolean
*/
protected $brokenOnDelete = false;
/**
* Used by onBeforeWrite() to ensure child classes call parent::onBeforeWrite()
* @var boolean
*/
protected $brokenOnWrite = false;
/**
* Should dataobjects be validated before they are written?
*/
private static $validation_enabled = true;
/**
* Static caches used by relevant functions.
*/
public static $cache_has_own_table = array();
public static $cache_has_own_table_field = array();
protected static $_cache_get_one;
protected static $_cache_get_class_ancestry;
protected static $_cache_composite_fields = array();
protected static $_cache_field_labels = array();
/**
* Non-static relationship cache, indexed by component name.
*/
protected $components;
/**
* Returns when validation on DataObjects is enabled.
* @return bool
*/
static function get_validation_enabled() {
return self::$validation_enabled;
}
/**
* Set whether DataObjects should be validated before they are written.
* @param $enable bool
* @see DataObject::validate()
*/
static function set_validation_enabled($enable) {
self::$validation_enabled = (bool) $enable;
}
/**
* Return the complete map of fields on this object, including "Created", "LastEdited" and "ClassName".
* See {@link custom_database_fields()} for a getter that excludes these "base fields".
*
* @param string $class
* @return array
*/
public static function database_fields($class) {
if(get_parent_class($class) == 'DataObject') {
return array_merge (
array (
'ClassName' => "Enum('" . implode(', ', ClassInfo::subclassesFor($class)) . "')",
'Created' => 'SS_Datetime',
'LastEdited' => 'SS_Datetime'
),
self::custom_database_fields($class)
);
}
return self::custom_database_fields($class);
}
/**
* Get all database columns explicitly defined on a class in {@link DataObject::$db}
* and {@link DataObject::$has_one}. Resolves instances of {@link CompositeDBField}
* into the actual database fields, rather than the name of the field which
* might not equate a database column.
*
* Does not include "base fields" like "ID", "ClassName", "Created", "LastEdited",
* see {@link database_fields()}.
*
* @uses CompositeDBField->compositeDatabaseFields()
*
* @param string $class
* @return array Map of fieldname to specification, similiar to {@link DataObject::$db}.
*/
public static function custom_database_fields($class) {
$fields = Config::inst()->get($class, 'db', Config::UNINHERITED);
foreach(self::composite_fields($class, false) as $fieldName => $fieldClass) {
// 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) {
$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) {
$fields[$field . 'ID'] = 'ForeignKey';
}
return (array)$fields;
}
/**
* Returns the field class if the given db field on the class is a composite field.
* Will check all applicable ancestor classes and aggregate results.
*/
static function is_composite_field($class, $name, $aggregated = true) {
if(!isset(DataObject::$_cache_composite_fields[$class])) self::cache_composite_fields($class);
if(isset(DataObject::$_cache_composite_fields[$class][$name])) {
return DataObject::$_cache_composite_fields[$class][$name];
} else if($aggregated && $class != 'DataObject' && ($parentClass=get_parent_class($class)) != 'DataObject') {
return self::is_composite_field($parentClass, $name);
}
}
/**
* 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.
*/
static function composite_fields($class, $aggregated = true) {
if(!isset(DataObject::$_cache_composite_fields[$class])) self::cache_composite_fields($class);
$compositeFields = DataObject::$_cache_composite_fields[$class];
if($aggregated && $class != 'DataObject' && ($parentClass=get_parent_class($class)) != 'DataObject') {
$compositeFields = array_merge($compositeFields,
self::composite_fields($parentClass));
}
return $compositeFields;
}
/**
* Internal cacher for the composite field information
*/
private static function cache_composite_fields($class) {
$compositeFields = array();
$fields = Config::inst()->get($class, 'db', Config::UNINHERITED);
if($fields) foreach($fields as $fieldName => $fieldClass) {
if(!is_string($fieldClass)) continue;
// Strip off any parameters
$bPos = strpos('(', $fieldClass);
if($bPos !== FALSE) $fieldClass = substr(0,$bPos, $fieldClass);
// Test to see if it implements CompositeDBField
if(ClassInfo::classImplements($fieldClass, 'CompositeDBField')) {
$compositeFields[$fieldName] = $fieldClass;
}
}
DataObject::$_cache_composite_fields[$class] = $compositeFields;
}
/**
* Construct a new DataObject.
*
* @param array|null $record This will be null for a new database record. Alternatively, you can pass an array of
* field values. Normally this contructor is only used by the internal systems that get objects from the database.
* @param boolean $isSingleton This this to true if this is a singleton() object, a stub for calling methods. Singletons
* don't have their defaults set.
*/
function __construct($record = null, $isSingleton = false, $model = null) {
parent::__construct();
// Set the fields data.
if(!$record) {
$record = array(
'ID' => 0,
'ClassName' => get_class($this),
'RecordClassName' => get_class($this)
);
}
if(!is_array($record)) {
if(is_object($record)) $passed = "an object of type '$record->class'";
else $passed = "The value '$record'";
user_error("DataObject::__construct passed $passed. It's supposed to be passed an array,
taken straight from the database. Perhaps you should use DataList::create()->First(); instead?", E_USER_WARNING);
$record = null;
}
// Set $this->record to $record, but ignore NULLs
$this->record = array();
foreach($record as $k => $v) {
// Ensure that ID is stored as a number and not a string
// To do: this kind of clean-up should be done on all numeric fields, in some relatively
// performant manner
if($v !== null) {
if($k == 'ID' && is_numeric($v)) $this->record[$k] = (int)$v;
else $this->record[$k] = $v;
}
}
// Identify fields that should be lazy loaded, but only on existing records
if(!empty($record['ID'])) {
$currentObj = get_class($this);
while($currentObj != 'DataObject') {
$fields = self::custom_database_fields($currentObj);
foreach($fields as $field => $type) {
if(!array_key_exists($field, $record)) $this->record[$field.'_Lazy'] = $currentObj;
}
$currentObj = get_parent_class($currentObj);
}
}
$this->original = $this->record;
// Keep track of the modification date of all the data sourced to make this page
// From this we create a Last-Modified HTTP header
if(isset($record['LastEdited'])) {
HTTP::register_modification_date($record['LastEdited']);
}
// Must be called after parent constructor
if(!$isSingleton && (!isset($this->record['ID']) || !$this->record['ID'])) {
$this->populateDefaults();
}
// prevent populateDefaults() and setField() from marking overwritten defaults as changed
$this->changed = array();
$this->model = $model ? $model : DataModel::inst();
}
/**
* Set the DataModel
*/
function setDataModel(DataModel $model) {
$this->model = $model;
}
/**
* Destroy all of this objects dependant objects and local caches.
* You'll need to call this to get the memory of an object that has components or extensions freed.
*/
function destroy() {
//$this->destroyed = true;
gc_collect_cycles();
$this->flushCache(false);
}
/**
* Create a duplicate of this node.
* Note: now also duplicates relations.
*
* @param $doWrite Perform a write() operation before returning the object. If this is true, it will create the duplicate in the database.
* @return DataObject A duplicate of this node. The exact type will be the type of this node.
*/
function duplicate($doWrite = true) {
$className = $this->class;
$clone = new $className( $this->toMap(), false, $this->model );
$clone->ID = 0;
$clone->extend('onBeforeDuplicate', $this, $doWrite);
if($doWrite) {
$clone->write();
$this->duplicateManyManyRelations($this, $clone);
}
$clone->extend('onAfterDuplicate', $this, $doWrite);
return $clone;
}
/**
* Copies the many_many and belongs_many_many relations from one object to another instance of the name of object
* The destinationObject must be written to the database already and have an ID. Writing is performed automatically when adding the new relations.
*
* @param $sourceObject the source object to duplicate from
* @param $destinationObject the destination object to populate with the duplicated relations
* @return DataObject with the new many_many relations copied in
*/
protected function duplicateManyManyRelations($sourceObject, $destinationObject) {
if (!$destinationObject || $destinationObject->ID < 1) user_error("Can't duplicate relations for an object that has not been written to the database", E_USER_ERROR);
//duplicate complex relations
// DO NOT copy has_many relations, because copying the relation would result in us changing the has_one relation
// on the other side of this relation to point at the copy and no longer the original (being a has_one, it can
// only point at one thing at a time). So, all relations except has_many can and are copied
if ($sourceObject->has_one()) foreach($sourceObject->has_one() as $name => $type) {
$this->duplicateRelations($sourceObject, $destinationObject, $name);
}
if ($sourceObject->many_many()) foreach($sourceObject->many_many() as $name => $type) { //many_many include belongs_many_many
$this->duplicateRelations($sourceObject, $destinationObject, $name);
}
return $destinationObject;
}
/**
* Helper function to duplicate relations from one object to another
* @param $sourceObject the source object to duplicate from
* @param $destinationObject the destination object to populate with the duplicated relations
* @param $name the name of the relation to duplicate (e.g. members)
*/
private function duplicateRelations($sourceObject, $destinationObject, $name) {
$relations = $sourceObject->$name();
if ($relations) {
if ($relations instanceOf RelationList) { //many-to-something relation
if ($relations->Count() > 0) { //with more than one thing it is related to
foreach($relations as $relation) {
$destinationObject->$name()->add($relation);
}
}
} else { //one-to-one relation
$destinationObject->$name = $relations;
}
}
}
/**
* Set the ClassName attribute. {@link $class} is also updated.
* Warning: This will produce an inconsistent record, as the object
* instance will not automatically switch to the new subclass.
* Please use {@link newClassInstance()} for this purpose,
* or destroy and reinstanciate the record.
*
* @param string $className The new ClassName attribute (a subclass of {@link DataObject})
*/
function setClassName($className) {
$className = trim($className);
if(!$className || !is_subclass_of($className, 'DataObject')) return;
$this->class = $className;
$this->setField("ClassName", $className);
}
/**
* Create a new instance of a different class from this object's record.
* This is useful when dynamically changing the type of an instance. Specifically,
* it ensures that the instance of the class is a match for the className of the
* record. Don't set the {@link DataObject->class} or {@link DataObject->ClassName}
* property manually before calling this method, as it will confuse change detection.
*
* If the new class is different to the original class, defaults are populated again
* because this will only occur automatically on instantiation of a DataObject if
* there is no record, or the record has no ID. In this case, we do have an ID but
* we still need to repopulate the defaults.
*
* @param string $newClassName The name of the new class
*
* @return DataObject The new instance of the new class, The exact type will be of the class name provided.
*/
function newClassInstance($newClassName) {
$originalClass = $this->ClassName;
$newInstance = new $newClassName(array_merge(
$this->record,
array(
'ClassName' => $originalClass,
'RecordClassName' => $originalClass,
)
), false, $this->model);
if($newClassName != $originalClass) {
$newInstance->setClassName($newClassName);
$newInstance->populateDefaults();
$newInstance->forceChange();
}
return $newInstance;
}
/**
* Adds methods from the extensions.
* Called by Object::__construct() once per class.
*/
function defineMethods() {
parent::defineMethods();
// Define the extra db fields - this is only necessary for extensions added in the
// class definition. Object::add_extension() will call this at definition time for
// those objects, which is a better mechanism. Perhaps extensions defined inside the
// class def can somehow be applied at definiton time also?
if($this->extension_instances) foreach($this->extension_instances as $i => $instance) {
if(!$instance->class) {
$class = get_class($instance);
user_error("DataObject::defineMethods(): Please ensure {$class}::__construct() calls parent::__construct()", E_USER_ERROR);
}
}
if($this->class == 'DataObject') return;
// Set up accessors for joined items
if($manyMany = $this->many_many()) {
foreach($manyMany as $relationship => $class) {
$this->addWrapperMethod($relationship, 'getManyManyComponents');
}
}
if($hasMany = $this->has_many()) {
foreach($hasMany as $relationship => $class) {
$this->addWrapperMethod($relationship, 'getComponents');
}
}
if($hasOne = $this->has_one()) {
foreach($hasOne as $relationship => $class) {
$this->addWrapperMethod($relationship, 'getComponent');
}
}
if($belongsTo = $this->belongs_to()) foreach(array_keys($belongsTo) as $relationship) {
$this->addWrapperMethod($relationship, 'getComponent');
}
}
/**
* Returns true if this object "exists", i.e., has a sensible value.
* The default behaviour for a DataObject is to return true if
* the object exists in the database, you can override this in subclasses.
*
* @return boolean true if this object exists
*/
public function exists() {
return ($this->record && $this->record['ID'] > 0);
}
/**
* Returns TRUE if all values (other than "ID") are
* considered empty (by weak boolean comparison).
* Only checks for fields listed in {@link custom_database_fields()}
*
* @todo Use DBField->hasValue()
*
* @return boolean
*/
public function isEmpty(){
$isEmpty = true;
$customFields = self::custom_database_fields(get_class($this));
if($map = $this->toMap()){
foreach($map as $k=>$v){
// only look at custom fields
if(!array_key_exists($k, $customFields)) continue;
$dbObj = ($v instanceof DBField) ? $v : $this->dbObject($k);
$isEmpty = ($isEmpty && !$dbObj->exists());
}
}
return $isEmpty;
}
/**
* Get the user friendly singular name of this DataObject.
* If the name is not defined (by redefining $singular_name in the subclass),
* this returns the class name.
*
* @return string User friendly singular name of this DataObject
*/
function singular_name() {
if(!$name = $this->stat('singular_name')) {
$name = ucwords(trim(strtolower(preg_replace('/_?([A-Z])/', ' $1', $this->class))));
}
return $name;
}
/**
* Get the translated user friendly singular name of this DataObject
* same as singular_name() but runs it through the translating function
*
* Translating string is in the form:
* $this->class.SINGULARNAME
* Example:
* Page.SINGULARNAME
*
* @return string User friendly translated singular name of this DataObject
*/
function i18n_singular_name() {
return _t($this->class.'.SINGULARNAME', $this->singular_name());
}
/**
* Get the user friendly plural name of this DataObject
* If the name is not defined (by renaming $plural_name in the subclass),
* this returns a pluralised version of the class name.
*
* @return string User friendly plural name of this DataObject
*/
function plural_name() {
if($name = $this->stat('plural_name')) {
return $name;
} else {
$name = $this->singular_name();
if(substr($name,-1) == 'e') $name = substr($name,0,-1);
else if(substr($name,-1) == 'y') $name = substr($name,0,-1) . 'ie';
return ucfirst($name . 's');
}
}
/**
* Get the translated user friendly plural name of this DataObject
* Same as plural_name but runs it through the translation function
* Translation string is in the form:
* $this->class.PLURALNAME
* Example:
* Page.PLURALNAME
*
* @return string User friendly translated plural name of this DataObject
*/
function i18n_plural_name()
{
$name = $this->plural_name();
return _t($this->class.'.PLURALNAME', $name);
}
/**
* Standard implementation of a title/label for a specific
* record. Tries to find properties 'Title' or 'Name',
* and falls back to the 'ID'. Useful to provide
* user-friendly identification of a record, e.g. in errormessages
* or UI-selections.
*
* Overload this method to have a more specialized implementation,
* e.g. for an Address record this could be:
* <code>
* public function getTitle() {
* return "{$this->StreetNumber} {$this->StreetName} {$this->City}";
* }
* </code>
*
* @return string
*/
public function getTitle() {
if($this->hasDatabaseField('Title')) return $this->getField('Title');
if($this->hasDatabaseField('Name')) return $this->getField('Name');
return "#{$this->ID}";
}
/**
* Returns the associated database record - in this case, the object itself.
* This is included so that you can call $dataOrController->data() and get a DataObject all the time.
*
* @return DataObject Associated database record
*/
public function data() {
return $this;
}
/**
* Convert this object to a map.
*
* @return array The data as a map.
*/
public function toMap() {
$this->loadLazyFields();
return $this->record;
}
/**
* Update a number of fields on this object, given a map of the desired changes.
*
* The field names can be simple names, or you can use a dot syntax to access $has_one relations.
* For example, array("Author.FirstName" => "Jim") will set $this->Author()->FirstName to "Jim".
*
* update() doesn't write the main object, but if you use the dot syntax, it will write()
* the related objects that it alters.
*
* @param array $data A map of field name to data values to update.
*/
public function update($data) {
foreach($data as $k => $v) {
// Implement dot syntax for updates
if(strpos($k,'.') !== false) {
$relations = explode('.', $k);
$fieldName = array_pop($relations);
$relObj = $this;
foreach($relations as $i=>$relation) {
// no support for has_many or many_many relationships,
// as the updater wouldn't know which object to write to (or create)
if($relObj->$relation() instanceof DataObject) {
$relObj = $relObj->$relation();
// If the intermediate relationship objects have been created, then write them
if($i<sizeof($relation)-1 && !$relObj->ID) $relObj->write();
} else {
user_error(
"DataObject::update(): Can't traverse relationship '$relation'," .
"it has to be a has_one relationship or return a single DataObject",
E_USER_NOTICE
);
// unset relation object so we don't write properties to the wrong object
unset($relObj);
break;
}
}
if($relObj) {
$relObj->$fieldName = $v;
$relObj->write();
$relObj->flushCache();
} else {
user_error("Couldn't follow dot syntax '$k' on '$this->class' object", E_USER_WARNING);
}
} else {
$this->$k = $v;
}
}
}
/**
* Pass changes as a map, and try to
* get automatic casting for these fields.
* Doesn't write to the database. To write the data,
* use the write() method.
*
* @param array $data A map of field name to data values to update.
*/
public function castedUpdate($data) {
foreach($data as $k => $v) {
$this->setCastedField($k,$v);
}
}
/**
* Merges data and relations from another object of same class,
* without conflict resolution. Allows to specify which
* dataset takes priority in case its not empty.
* has_one-relations are just transferred with priority 'right'.
* has_many and many_many-relations are added regardless of priority.
*
* Caution: has_many/many_many relations are moved rather than duplicated,
* meaning they are not connected to the merged object any longer.
* Caution: Just saves updated has_many/many_many relations to the database,
* doesn't write the updated object itself (just writes the object-properties).
* Caution: Does not delete the merged object.
* Caution: Does now overwrite Created date on the original object.
*
* @param $obj DataObject
* @param $priority String left|right Determines who wins in case of a conflict (optional)
* @param $includeRelations Boolean Merge any existing relations (optional)
* @param $overwriteWithEmpty Boolean Overwrite existing left values with empty right values.
* Only applicable with $priority='right'. (optional)
* @return Boolean
*/
public function merge($rightObj, $priority = 'right', $includeRelations = true, $overwriteWithEmpty = false) {
$leftObj = $this;
if($leftObj->ClassName != $rightObj->ClassName) {
// we can't merge similiar subclasses because they might have additional relations
user_error("DataObject->merge(): Invalid object class '{$rightObj->ClassName}'
(expected '{$leftObj->ClassName}').", E_USER_WARNING);
return false;
}
if(!$rightObj->ID) {
user_error("DataObject->merge(): Please write your merged-in object to the database before merging,
to make sure all relations are transferred properly.').", E_USER_WARNING);
return false;
}
// makes sure we don't merge data like ID or ClassName
$leftData = $leftObj->inheritedDatabaseFields();
$rightData = $rightObj->inheritedDatabaseFields();
foreach($rightData as $key=>$rightVal) {
// don't merge conflicting values if priority is 'left'
if($priority == 'left' && $leftObj->{$key} !== $rightObj->{$key}) continue;
// don't overwrite existing left values with empty right values (if $overwriteWithEmpty is set)
if($priority == 'right' && !$overwriteWithEmpty && empty($rightObj->{$key})) continue;
// TODO remove redundant merge of has_one fields
$leftObj->{$key} = $rightObj->{$key};
}
// merge relations
if($includeRelations) {
if($manyMany = $this->many_many()) {
foreach($manyMany as $relationship => $class) {
$leftComponents = $leftObj->getManyManyComponents($relationship);
$rightComponents = $rightObj->getManyManyComponents($relationship);
if($rightComponents && $rightComponents->exists()) $leftComponents->addMany($rightComponents->column('ID'));
$leftComponents->write();
}
}
if($hasMany = $this->has_many()) {
foreach($hasMany as $relationship => $class) {
$leftComponents = $leftObj->getComponents($relationship);
$rightComponents = $rightObj->getComponents($relationship);
if($rightComponents && $rightComponents->exists()) $leftComponents->addMany($rightComponents->column('ID'));
$leftComponents->write();
}
}
if($hasOne = $this->has_one()) {
foreach($hasOne as $relationship => $class) {
$leftComponent = $leftObj->getComponent($relationship);
$rightComponent = $rightObj->getComponent($relationship);
if($leftComponent->exists() && $rightComponent->exists() && $priority == 'right') {
$leftObj->{$relationship . 'ID'} = $rightObj->{$relationship . 'ID'};
}
}
}
}
return true;
}
/**
* Forces the record to think that all its data has changed.
* Doesn't write to the database. Only sets fields as changed
* if they are not already marked as changed.
*/
public function forceChange() {
// Ensure lazy fields loaded
$this->loadLazyFields();
// $this->record might not contain the blank values so we loop on $this->inheritedDatabaseFields() as well
$fieldNames = array_unique(array_merge(array_keys($this->record), array_keys($this->inheritedDatabaseFields())));
foreach($fieldNames as $fieldName) {
if(!isset($this->changed[$fieldName])) $this->changed[$fieldName] = 1;
// Populate the null values in record so that they actually get written
if(!isset($this->record[$fieldName])) $this->record[$fieldName] = null;
}
// @todo Find better way to allow versioned to write a new version after forceChange
if($this->isChanged('Version')) unset($this->changed['Version']);
}
/**
* Validate the current object.
*
* By default, there is no validation - objects are always valid! However, you can overload this method in your
* DataObject sub-classes to specify custom validation, or use the hook through DataExtension.
*
* Invalid objects won't be able to be written - a warning will be thrown and no write will occur. onBeforeWrite()
* and onAfterWrite() won't get called either.
*
* It is expected that you call validate() in your own application to test that an object is valid before attempting
* a write, and respond appropriately if it isnt'.
*
* @return A {@link ValidationResult} object
*/
protected function validate() {
$result = new ValidationResult();
$this->extend('validate', $result);
return $result;
}
/**
* Event handler called before writing to the database.
* You can overload this to clean up or otherwise process data before writing it to the
* database. Don't forget to call parent::onBeforeWrite(), though!
*
* This called after {@link $this->validate()}, so you can be sure that your data is valid.
*
* @uses DataExtension->onBeforeWrite()
*/
protected function onBeforeWrite() {
$this->brokenOnWrite = false;
$dummy = null;
$this->extend('onBeforeWrite', $dummy);
}
/**
* Event handler called after writing to the database.
* You can overload this to act upon changes made to the data after it is written.
* $this->changed will have a record
* database. Don't forget to call parent::onAfterWrite(), though!
*
* @uses DataExtension->onAfterWrite()
*/
protected function onAfterWrite() {
$dummy = null;
$this->extend('onAfterWrite', $dummy);
}
/**
* Event handler called before deleting from the database.
* You can overload this to clean up or otherwise process data before delete this
* record. Don't forget to call parent::onBeforeDelete(), though!
*
* @uses DataExtension->onBeforeDelete()
*/
protected function onBeforeDelete() {
$this->brokenOnDelete = false;
$dummy = null;
$this->extend('onBeforeDelete', $dummy);
}
protected function onAfterDelete() {
$this->extend('onAfterDelete');
}
/**
* Load the default values in from the self::$defaults array.
* Will traverse the defaults of the current class and all its parent classes.
* Called by the constructor when creating new records.
*
* @uses DataExtension->populateDefaults()
*/
public function populateDefaults() {
$classes = array_reverse(ClassInfo::ancestry($this));
foreach($classes as $class) {
$defaults = Config::inst()->get($class, 'defaults', Config::UNINHERITED);
if($defaults && !is_array($defaults)) {
user_error("Bad '$this->class' defaults given: " . var_export($defaults, true),
E_USER_WARNING);
$defaults = null;
}
if($defaults) foreach($defaults as $fieldName => $fieldValue) {
// SRM 2007-03-06: Stricter check
if(!isset($this->$fieldName) || $this->$fieldName === null) {
$this->$fieldName = $fieldValue;
}
// Set many-many defaults with an array of ids
if(is_array($fieldValue) && $this->many_many($fieldName)) {
$manyManyJoin = $this->$fieldName();
$manyManyJoin->setByIdList($fieldValue);
}
}
if($class == 'DataObject') {
break;
}
}
$this->extend('populateDefaults');
}
/**
* Writes all changes to this object to the database.
* - It will insert a record whenever ID isn't set, otherwise update.
* - All relevant tables will be updated.
* - $this->onBeforeWrite() gets called beforehand.
* - Extensions such as Versioned will ammend the database-write to ensure that a version is saved.
*
* @uses DataExtension->augmentWrite()
*
* @param boolean $showDebug Show debugging information
* @param boolean $forceInsert Run INSERT command rather than UPDATE, even if record already exists
* @param boolean $forceWrite Write to database even if there are no changes
* @param boolean $writeComponents Call write() on all associated component instances which were previously
* retrieved through {@link getComponent()}, {@link getComponents()} or {@link getManyManyComponents()}
* (Default: false)
*
* @return int The ID of the record
* @throws ValidationException Exception that can be caught and handled by the calling function
*/
public function write($showDebug = false, $forceInsert = false, $forceWrite = false, $writeComponents = false) {
$firstWrite = false;
$this->brokenOnWrite = true;
$isNewRecord = false;
if(self::get_validation_enabled()) {
$valid = $this->validate();
if(!$valid->valid()) {
// Used by DODs to clean up after themselves, eg, Versioned
$this->extend('onAfterSkippedWrite');
throw new ValidationException($valid, "Validation error writing a $this->class object: " . $valid->message() . ". Object not written.", E_USER_WARNING);
return false;
}
}
$this->onBeforeWrite();
if($this->brokenOnWrite) {
user_error("$this->class has a broken onBeforeWrite() function. Make sure that you call parent::onBeforeWrite().", E_USER_ERROR);
}
// New record = everything has changed
if(($this->ID && is_numeric($this->ID)) && !$forceInsert) {
$dbCommand = 'update';
// Update the changed array with references to changed obj-fields
foreach($this->record as $k => $v) {
if(is_object($v) && method_exists($v, 'isChanged') && $v->isChanged()) {
$this->changed[$k] = true;
}
}
} else{
$dbCommand = 'insert';
$this->changed = array();
foreach($this->record as $k => $v) {
$this->changed[$k] = 2;
}
$firstWrite = true;
}
// No changes made
if($this->changed) {
foreach($this->getClassAncestry() as $ancestor) {
if(self::has_own_table($ancestor))
$ancestry[] = $ancestor;
}
// Look for some changes to make
if(!$forceInsert) unset($this->changed['ID']);
$hasChanges = false;
foreach($this->changed as $fieldName => $changed) {
if($changed) {
$hasChanges = true;
break;
}
}
if($hasChanges || $forceWrite || !$this->record['ID']) {
// New records have their insert into the base data table done first, so that they can pass the
// generated primary key on to the rest of the manipulation
$baseTable = $ancestry[0];
if((!isset($this->record['ID']) || !$this->record['ID']) && isset($ancestry[0])) {
DB::query("INSERT INTO \"{$baseTable}\" (\"Created\") VALUES (" . DB::getConn()->now() . ")");
$this->record['ID'] = DB::getGeneratedID($baseTable);
$this->changed['ID'] = 2;
$isNewRecord = true;
}
// Divvy up field saving into a number of database manipulations
$manipulation = array();
if(isset($ancestry) && is_array($ancestry)) {
foreach($ancestry as $idx => $class) {
$classSingleton = singleton($class);
foreach($this->record as $fieldName => $fieldValue) {
if(isset($this->changed[$fieldName]) && $this->changed[$fieldName] && $fieldType = $classSingleton->hasOwnTableDatabaseField($fieldName)) {
$fieldObj = $this->dbObject($fieldName);
if(!isset($manipulation[$class])) $manipulation[$class] = array();
// if database column doesn't correlate to a DBField instance...
if(!$fieldObj) {
$fieldObj = DBField::create_field('Varchar', $this->record[$fieldName], $fieldName);
}
// Both CompositeDBFields and regular fields need to be repopulated
$fieldObj->setValue($this->record[$fieldName], $this->record);
if($class != $baseTable || $fieldName!='ID')
$fieldObj->writeToManipulation($manipulation[$class]);
}
}
// Add the class name to the base object
if($idx == 0) {
$manipulation[$class]['fields']["LastEdited"] = "'".SS_Datetime::now()->Rfc2822()."'";
if($dbCommand == 'insert') {
$manipulation[$class]['fields']["Created"] = "'".SS_Datetime::now()->Rfc2822()."'";
//echo "<li>$this->class - " .get_class($this);
$manipulation[$class]['fields']["ClassName"] = DB::getConn()->prepStringForDB($this->class);
}
}
// In cases where there are no fields, this 'stub' will get picked up on
if(self::has_own_table($class)) {
$manipulation[$class]['command'] = $dbCommand;
$manipulation[$class]['id'] = $this->record['ID'];
} else {
unset($manipulation[$class]);
}
}
}
$this->extend('augmentWrite', $manipulation);
// New records have their insert into the base data table done first, so that they can pass the
// generated ID on to the rest of the manipulation
if(isset($isNewRecord) && $isNewRecord && isset($manipulation[$baseTable])) {
$manipulation[$baseTable]['command'] = 'update';
}
DB::manipulate($manipulation);
$this->onAfterWrite();
$this->changed = null;
} elseif ( $showDebug ) {
echo "<b>Debug:</b> no changes for DataObject<br />";
// Used by DODs to clean up after themselves, eg, Versioned
$this->extend('onAfterSkippedWrite');
}
// Clears the cache for this object so get_one returns the correct object.
$this->flushCache();
if(!isset($this->record['Created'])) {
$this->record['Created'] = SS_Datetime::now()->Rfc2822();
}
$this->record['LastEdited'] = SS_Datetime::now()->Rfc2822();
} else {
// Used by DODs to clean up after themselves, eg, Versioned
$this->extend('onAfterSkippedWrite');
}
// Write relations as necessary
if($writeComponents) {
$this->writeComponents(true);
}
return $this->record['ID'];
}
/**
* Write the cached components to the database. Cached components could refer to two different instances of the same record.
*
* @param $recursive Recursively write components
*/
public function writeComponents($recursive = false) {
if(!$this->components) return;
foreach($this->components as $component) {
$component->write(false, false, false, $recursive);
}
}
/**
* Delete this data object.
* $this->onBeforeDelete() gets called.
* Note that in Versioned objects, both Stage and Live will be deleted.
* @uses DataExtension->augmentSQL()
*/
public function delete() {
$this->brokenOnDelete = true;
$this->onBeforeDelete();
if($this->brokenOnDelete) {
user_error("$this->class has a broken onBeforeDelete() function. Make sure that you call parent::onBeforeDelete().", E_USER_ERROR);
}
// Deleting a record without an ID shouldn't do anything
if(!$this->ID) throw new Exception("DataObject::delete() called on a DataObject without an ID");
// TODO: This is quite ugly. To improve:
// - move the details of the delete code in the DataQuery system
// - update the code to just delete the base table, and rely on cascading deletes in the DB to do the rest
// obviously, that means getting requireTable() to configure cascading deletes ;-)
$srcQuery = DataList::create($this->class, $this->model)->where("ID = $this->ID")->dataQuery()->query();
foreach($srcQuery->queriedTables() as $table) {
$query = new SQLQuery("*", array('"' . $table . '"'));
$query->setWhere("\"ID\" = $this->ID");
$query->setDelete(true);
$query->execute();
}
// Remove this item out of any caches
$this->flushCache();
$this->onAfterDelete();
$this->OldID = $this->ID;
$this->ID = 0;
}
/**
* Delete the record with the given ID.
*
* @param string $className The class name of the record to be deleted
* @param int $id ID of record to be deleted
*/
public static function delete_by_id($className, $id) {
$obj = DataObject::get_by_id($className, $id);
if($obj) {
$obj->delete();
} else {
user_error("$className object #$id wasn't found when calling DataObject::delete_by_id", E_USER_WARNING);
}
}
/**
* Get the class ancestry, including the current class name.
* The ancestry will be returned as an array of class names, where the 0th element
* will be the class that inherits directly from DataObject, and the last element
* will be the current class.
*
* @return array Class ancestry
*/
public function getClassAncestry() {
if(!isset(DataObject::$_cache_get_class_ancestry[$this->class])) {
DataObject::$_cache_get_class_ancestry[$this->class] = array($this->class);
while(($class = get_parent_class(DataObject::$_cache_get_class_ancestry[$this->class][0])) != "DataObject") {
array_unshift(DataObject::$_cache_get_class_ancestry[$this->class], $class);
}
}
return DataObject::$_cache_get_class_ancestry[$this->class];
}
/**
* Return a component object from a one to one relationship, as a DataObject.
* If no component is available, an 'empty component' will be returned.
*
* @param string $componentName Name of the component
*
* @return DataObject The component object. It's exact type will be that of the component.
*/
public function getComponent($componentName) {
if(isset($this->components[$componentName])) {
return $this->components[$componentName];
}
if($class = $this->has_one($componentName)) {
$joinField = $componentName . 'ID';
$joinID = $this->getField($joinField);
if($joinID) {
$component = $this->model->$class->byID($joinID);
}
if(!isset($component) || !$component) {
$component = $this->model->$class->newObject();
}
} elseif($class = $this->belongs_to($componentName)) {
$joinField = $this->getRemoteJoinField($componentName, 'belongs_to');
$joinID = $this->ID;
if($joinID) {
$component = DataObject::get_one($class, "\"$joinField\" = $joinID");
}
if(!isset($component) || !$component) {
$component = $this->model->$class->newObject();
$component->$joinField = $this->ID;
}
} else {
throw new Exception("DataObject->getComponent(): Could not find component '$componentName'.");
}
$this->components[$componentName] = $component;
return $component;
}
/**
* Returns a one-to-many relation as a HasManyList
*
* @param string $componentName Name of the component
* @param string $filter A filter to be inserted into the WHERE clause
* @param string|array $sort A sort expression to be inserted into the ORDER BY clause. If omitted, the static field $default_sort on the component class will be used.
* @param string $join A single join clause. This can be used for filtering, only 1 instance of each DataObject will be returned.
* @param string|array $limit A limit expression to be inserted into the LIMIT clause
*
* @return HasManyList The components of the one-to-many relationship.
*/
public function getComponents($componentName, $filter = "", $sort = "", $join = "", $limit = null) {
$result = null;
if(!$componentClass = $this->has_many($componentName)) {
user_error("DataObject::getComponents(): Unknown 1-to-many component '$componentName' on class '$this->class'", E_USER_ERROR);
}
$joinField = $this->getRemoteJoinField($componentName, 'has_many');
$result = new HasManyList($componentClass, $joinField);
if($this->model) $result->setDataModel($this->model);
$result = $result->forForeignID($this->ID);
$result = $result->where($filter)->limit($limit)->sort($sort);
if($join) $result = $result->join($join);
return $result;
}
/**
* Get the query object for a $has_many Component.
*
* @param string $componentName
* @param string $filter
* @param string|array $sort
* @param string $join
* @param string|array $limit
* @return SQLQuery
*/
public function getComponentsQuery($componentName, $filter = "", $sort = "", $join = "", $limit = "") {
if(!$componentClass = $this->has_many($componentName)) {
user_error("DataObject::getComponentsQuery(): Unknown 1-to-many component '$componentName' on class '$this->class'", E_USER_ERROR);
}
$joinField = $this->getRemoteJoinField($componentName, 'has_many');
$id = $this->getField("ID");
// get filter
$combinedFilter = "\"$joinField\" = '$id'";
if($filter) $combinedFilter .= " AND {$filter}";
return singleton($componentClass)->extendedSQL($combinedFilter, $sort, $limit, $join);
}
/**
* Find the foreign class of a relation on this DataObject, regardless of the relation type.
*
* @param $relationName Relation name.
* @return string Class name, or null if not found.
*/
public function getRelationClass($relationName) {
// Go through all relationship configuration fields.
$candidates = array_merge(
($relations = Config::inst()->get($this->class, 'has_one')) ? $relations : array(),
($relations = Config::inst()->get($this->class, 'has_many')) ? $relations : array(),
($relations = Config::inst()->get($this->class, 'many_many')) ? $relations : array(),
($relations = Config::inst()->get($this->class, 'belongs_many_many')) ? $relations : array(),
($relations = Config::inst()->get($this->class, 'belongs_to')) ? $relations : array()
);
if (isset($candidates[$relationName])) {
$remoteClass = $candidates[$relationName];
// If dot notation is present, extract just the first part that contains the class.
if(($fieldPos = strpos($remoteClass, '.'))!==false) {
return substr($remoteClass, 0, $fieldPos);
}
// Otherwise just return the class
return $remoteClass;
}
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'.
*
* @param string $component
* @param string $type the join type - either 'has_many' or 'belongs_to'
* @return string
*/
public function getRemoteJoinField($component, $type = 'has_many') {
$remoteClass = $this->$type($component, false);
if(!$remoteClass) {
throw new Exception("Unknown $type component '$component' on class '$this->class'");
}
if($fieldPos = strpos($remoteClass, '.')) {
return substr($remoteClass, $fieldPos + 1) . 'ID';
}
$remoteRelations = array_flip(Config::inst()->get($remoteClass, 'has_one'));
// look for remote has_one joins on this class or any parent classes
foreach(array_reverse(ClassInfo::ancestry($this)) as $class) {
if(array_key_exists($class, $remoteRelations)) return $remoteRelations[$class] . 'ID';
}
return 'ParentID';
}
/**
* Sets the component of a relationship.
* This should only need to be called internally,
* and is mainly due to the caching logic in {@link getComponents()}
* and {@link getManyManyComponents()}.
*
* @param string $componentName Name of the component
* @param DataObject|HasManyList|ManyManyList $componentValue Value of the component
* @deprecated 3.0 Not used internally anymore.
*/
public function setComponent($componentName, $componentValue) {
Deprecation::notice('3.0', 'Not used internally anymore.');
}
/**
* Returns a many-to-many component, as a ManyManyList.
* @param string $componentName Name of the many-many component
* @return ManyManyList The set of components
*
* @todo Implement query-params
*/
public function getManyManyComponents($componentName, $filter = "", $sort = "", $join = "", $limit = "") {
list($parentClass, $componentClass, $parentField, $componentField, $table) = $this->many_many($componentName);
$result = Injector::inst()->create('ManyManyList', $componentClass, $table, $componentField, $parentField,
$this->many_many_extraFields($componentName));
if($this->model) $result->setDataModel($this->model);
// If this is called on a singleton, then we return an 'orphaned relation' that can have the
// foreignID set elsewhere.
$result = $result->forForeignID($this->ID);
return $result->where($filter)->sort($sort)->limit($limit);
}
/**
* Return the class of a one-to-one component. If $component is null, return all of the one-to-one components and their classes.
*
* @param string $component Name of component
*
* @return string|array The class of the one-to-one component, or an array of all one-to-one components and their classes.
*/
public function has_one($component = null) {
$classes = ClassInfo::ancestry($this);
foreach($classes as $class) {
// Wait until after we reach DataObject
if(in_array($class, array('Object', 'ViewableData', 'DataObject'))) continue;
if($component) {
$hasOne = Config::inst()->get($class, 'has_one', Config::UNINHERITED);
if(isset($hasOne[$component])) {
return $hasOne[$component];
}
} else {
$newItems = (array)Config::inst()->get($class, 'has_one', Config::UNINHERITED);
// Validate the data
foreach($newItems as $k => $v) {
if(!is_string($k) || is_numeric($k) || !is_string($v)) user_error("$class::\$has_one has a bad entry: "
. var_export($k,true). " => " . var_export($v,true) . ". Each map key should be a relationship name, and the map value should be the data class to join to.", E_USER_ERROR);
}
$items = isset($items) ? array_merge($newItems, (array)$items) : $newItems;
}
}
return isset($items) ? $items : null;
}
/**
* Returns the class of a remote belongs_to relationship. If no component is specified a map of all components and
* their class name will be returned.
*
* @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|array
*/
public function belongs_to($component = null, $classOnly = true) {
$belongsTo = $this->config()->belongs_to;
if($component) {
if($belongsTo && array_key_exists($component, $belongsTo)) {
$belongsTo = $belongsTo[$component];
} else {
return false;
}
}
if($belongsTo && $classOnly) {
return preg_replace('/(.+)?\..+/', '$1', $belongsTo);
} else {
return $belongsTo ? $belongsTo : array();
}
}
/**
* Return all of the database fields defined in self::$db and all the parent classes.
* Doesn't include any fields specified by self::$has_one. Use $this->has_one() to get these fields
*
* @param string $fieldName Limit the output to a specific field name
* @return array The database fields
*/
public function db($fieldName = null) {
$classes = ClassInfo::ancestry($this);
$good = false;
$items = array();
foreach($classes as $class) {
// Wait until after we reach DataObject
if(!$good) {
if($class == 'DataObject') {
$good = true;
}
continue;
}
if($fieldName) {
$db = Config::inst()->get($class, 'db', Config::UNINHERITED);
if(isset($db[$fieldName])) {
return $db[$fieldName];
}
} else {
$newItems = (array)Config::inst()->get($class, 'db', Config::UNINHERITED);
// Validate the data
foreach($newItems as $k => $v) {
if(!is_string($k) || is_numeric($k) || !is_string($v)) user_error("$class::\$db has a bad entry: "
. var_export($k,true). " => " . var_export($v,true) . ". Each map key should be a property name, and the map value should be the property type.", E_USER_ERROR);
}
$items = isset($items) ? array_merge((array)$items, $newItems) : $newItems;
}
}
return $items;
}
/**
* Gets the class of a one-to-many relationship. If no $component is specified then an array of all the one-to-many
* relationships and their classes will be returned.
*
* @param string $component Name of 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|array
*/
public function has_many($component = null, $classOnly = true) {
$hasMany = $this->config()->has_many;
if($component) {
if($hasMany && array_key_exists($component, $hasMany)) {
$hasMany = $hasMany[$component];
} else {
return false;
}
}
if($hasMany && $classOnly) {
return preg_replace('/(.+)?\..+/', '$1', $hasMany);
} else {
return $hasMany ? $hasMany : array();
}
}
/**
* Return the many-to-many extra fields specification.
*
* If you don't specify a component name, it returns all
* extra fields for all components available.
*
* @param string $component Name of component
* @return array
*/
public function many_many_extraFields($component = null) {
$classes = ClassInfo::ancestry($this);
foreach($classes as $class) {
if(in_array($class, array('ViewableData', 'Object', 'DataObject'))) continue;
$relationName = null;
// Find extra fields for one component
if($component) {
$SNG_class = singleton($class);
$extraFields = $SNG_class->stat('many_many_extraFields');
// Extra fields are immediately available on this class
if(isset($extraFields[$component])) {
return $extraFields[$component];
}
$manyMany = $SNG_class->stat('many_many');
$candidate = (isset($manyMany[$component])) ? $manyMany[$component] : null;
if($candidate) {
$SNG_candidate = singleton($candidate);
$candidateManyMany = $SNG_candidate->stat('belongs_many_many');
// Find the relation given the class
if($candidateManyMany) foreach($candidateManyMany as $relation => $relatedClass) {
if($relatedClass == $class) {
$relationName = $relation;
break;
}
}
if($relationName) {
$extraFields = $SNG_candidate->stat('many_many_extraFields');
if(isset($extraFields[$relationName])) {
return $extraFields[$relationName];
}
}
}
$manyMany = $SNG_class->stat('belongs_many_many');
$candidate = (isset($manyMany[$component])) ? $manyMany[$component] : null;
if($candidate) {
$SNG_candidate = singleton($candidate);
$candidateManyMany = $SNG_candidate->stat('many_many');
// Find the relation given the class
if($candidateManyMany) foreach($candidateManyMany as $relation => $relatedClass) {
if($relatedClass == $class) {
$relationName = $relation;
}
}
$extraFields = $SNG_candidate->stat('many_many_extraFields');
if(isset($extraFields[$relationName])) {
return $extraFields[$relationName];
}
}
} else {
// Find all the extra fields for all components
$newItems = eval("return (array){$class}::\$many_many_extraFields;");
foreach($newItems as $k => $v) {
if(!is_array($v)) {
user_error(
"$class::\$many_many_extraFields has a bad entry: "
. var_export($k, true) . " => " . var_export($v, true)
. ". Each many_many_extraFields entry should map to a field specification array.",
E_USER_ERROR
);
}
}
return isset($items) ? array_merge($newItems, $items) : $newItems;
}
}
}
/**
* Return information about a many-to-many component.
* The return value is an array of (parentclass, childclass). If $component is null, then all many-many
* components are returned.
*
* @param string $component Name of component
*
* @return array An array of (parentclass, childclass), or an array of all many-many components
*/
public function many_many($component = null) {
$classes = ClassInfo::ancestry($this);
foreach($classes as $class) {
// Wait until after we reach DataObject
if(in_array($class, array('ViewableData', 'Object', 'DataObject'))) continue;
if($component) {
$manyMany = Config::inst()->get($class, 'many_many', Config::UNINHERITED);
// Try many_many
$candidate = (isset($manyMany[$component])) ? $manyMany[$component] : null;
if($candidate) {
$parentField = $class . "ID";
$childField = ($class == $candidate) ? "ChildID" : $candidate . "ID";
return array($class, $candidate, $parentField, $childField, "{$class}_$component");
}
// Try belongs_many_many
$belongsManyMany = Config::inst()->get($class, 'belongs_many_many', Config::UNINHERITED);
$candidate = (isset($belongsManyMany[$component])) ? $belongsManyMany[$component] : null;
if($candidate) {
$childField = $candidate . "ID";
// We need to find the inverse component name
$otherManyMany = Config::inst()->get($candidate, 'many_many', Config::UNINHERITED);
if(!$otherManyMany) {
user_error("Inverse component of $candidate not found ({$this->class})", E_USER_ERROR);
}
foreach($otherManyMany as $inverseComponentName => $candidateClass) {
if($candidateClass == $class || is_subclass_of($class, $candidateClass)) {
$parentField = ($class == $candidate) ? "ChildID" : $candidateClass . "ID";
// HACK HACK HACK!
if($component == 'NestedProducts') {
$parentField = $candidateClass . "ID";
}
return array($class, $candidate, $parentField, $childField, "{$candidate}_$inverseComponentName");
}
}
user_error("Orphaned \$belongs_many_many value for $this->class.$component", E_USER_ERROR);
}
} else {
$newItems = (array)Config::inst()->get($class, 'many_many', Config::UNINHERITED);
// Validate the data
foreach($newItems as $k => $v) {
if(!is_string($k) || is_numeric($k) || !is_string($v)) user_error("$class::\$many_many has a bad entry: "
. var_export($k,true). " => " . var_export($v,true) . ". Each map key should be a relationship name, and the map value should be the data class to join to.", E_USER_ERROR);
}
$items = isset($items) ? array_merge($newItems, $items) : $newItems;
$newItems = (array)Config::inst()->get($class, 'belongs_many_many', Config::UNINHERITED);
// Validate the data
foreach($newItems as $k => $v) {
if(!is_string($k) || is_numeric($k) || !is_string($v)) user_error("$class::\$belongs_many_many has a bad entry: "
. var_export($k,true). " => " . var_export($v,true) . ". Each map key should be a relationship name, and the map value should be the data class to join to.", E_USER_ERROR);
}
$items = isset($items) ? array_merge($newItems, $items) : $newItems;
}
}
return isset($items) ? $items : null;
}
/**
* This returns an array (if it exists) describing the database extensions that are required, or false if none
*
* This is experimental, and is currently only a Postgres-specific enhancement.
*
* @return array or false
*/
function database_extensions($class){
$extensions = Config::inst()->get($class, 'database_extensions', Config::UNINHERITED);
if($extensions)
return $extensions;
else
return false;
}
/**
* Generates a SearchContext to be used for building and processing
* a generic search form for properties on this object.
*
* @return SearchContext
*/
public function getDefaultSearchContext() {
return new SearchContext(
$this->class,
$this->scaffoldSearchFields(),
$this->defaultSearchFilters()
);
}
/**
* Determine which properties on the DataObject are
* searchable, and map them to their default {@link FormField}
* representations. Used for scaffolding a searchform for {@link ModelAdmin}.
*
* Some additional logic is included for switching field labels, based on
* how generic or specific the field type is.
*
* Used by {@link SearchContext}.
*
* @param array $_params
* 'fieldClasses': Associative array of field names as keys and FormField classes as values
* 'restrictFields': Numeric array of a field name whitelist
* @return FieldList
*/
public function scaffoldSearchFields($_params = null) {
$params = array_merge(
array(
'fieldClasses' => false,
'restrictFields' => false
),
(array)$_params
);
$fields = new FieldList();
foreach($this->searchableFields() as $fieldName => $spec) {
if($params['restrictFields'] && !in_array($fieldName, $params['restrictFields'])) continue;
// If a custom fieldclass is provided as a string, use it
if($params['fieldClasses'] && isset($params['fieldClasses'][$fieldName])) {
$fieldClass = $params['fieldClasses'][$fieldName];
$field = new $fieldClass($fieldName);
// If we explicitly set a field, then construct that
} else if(isset($spec['field'])) {
// If it's a string, use it as a class name and construct
if(is_string($spec['field'])) {
$fieldClass = $spec['field'];
$field = new $fieldClass($fieldName);
// If it's a FormField object, then just use that object directly.
} else if($spec['field'] instanceof FormField) {
$field = $spec['field'];
// Otherwise we have a bug
} else {
user_error("Bad value for searchable_fields, 'field' value: " . var_export($spec['field'], true), E_USER_WARNING);
}
// Otherwise, use the database field's scaffolder
} else {
$field = $this->relObject($fieldName)->scaffoldSearchField();
}
if (strstr($fieldName, '.')) {
$field->setName(str_replace('.', '__', $fieldName));
}
$field->setTitle($spec['title']);
$fields->push($field);
}
return $fields;
}
/**
* Scaffold a simple edit form for all properties on this dataobject,
* based on default {@link FormField} mapping in {@link DBField::scaffoldFormField()}.
* Field labels/titles will be auto generated from {@link DataObject::fieldLabels()}.
*
* @uses FormScaffolder
*
* @param array $_params Associative array passing through properties to {@link FormScaffolder}.
* @return FieldList
*/
public function scaffoldFormFields($_params = null) {
$params = array_merge(
array(
'tabbed' => false,
'includeRelations' => false,
'restrictFields' => false,
'fieldClasses' => false,
'ajaxSafe' => false
),
(array)$_params
);
$fs = new FormScaffolder($this);
$fs->tabbed = $params['tabbed'];
$fs->includeRelations = $params['includeRelations'];
$fs->restrictFields = $params['restrictFields'];
$fs->fieldClasses = $params['fieldClasses'];
$fs->ajaxSafe = $params['ajaxSafe'];
return $fs->getFieldList();
}
/**
* Centerpiece of every data administration interface in Silverstripe,
* which returns a {@link FieldList} suitable for a {@link Form} object.
* If not overloaded, we're using {@link scaffoldFormFields()} to automatically
* generate this set. To customize, overload this method in a subclass
* or extended onto it by using {@link DataExtension->updateCMSFields()}.
*
* <code>
* klass MyCustomClass extends DataObject {
* static $db = array('CustomProperty'=>'Boolean');
*
* public function getCMSFields() {
* $fields = parent::getCMSFields();
* $fields->addFieldToTab('Root.Content',new CheckboxField('CustomProperty'));
* return $fields;
* }
* }
* </code>
*
* @see Good example of complex FormField building: SiteTree::getCMSFields()
*
* @return FieldList Returns a TabSet for usage within the CMS - don't use for frontend forms.
*/
public function getCMSFields() {
$tabbedFields = $this->scaffoldFormFields(array(
// Don't allow has_many/many_many relationship editing before the record is first saved
'includeRelations' => ($this->ID > 0),
'tabbed' => true,
'ajaxSafe' => true
));
$this->extend('updateCMSFields', $tabbedFields);
return $tabbedFields;
}
/**
* need to be overload by solid dataobject, so that the customised actions of that dataobject,
* including that dataobject's extensions customised actions could be added to the EditForm.
*
* @return an Empty FieldList(); need to be overload by solid subclass
*/
public function getCMSActions() {
$actions = new FieldList();
$this->extend('updateCMSActions', $actions);
return $actions;
}
/**
* Used for simple frontend forms without relation editing
* or {@link TabSet} behaviour. Uses {@link scaffoldFormFields()}
* by default. To customize, either overload this method in your
* subclass, or extend it by {@link DataExtension->updateFrontEndFields()}.
*
* @todo Decide on naming for "website|frontend|site|page" and stick with it in the API
*
* @param array $params See {@link scaffoldFormFields()}
* @return FieldList Always returns a simple field collection without TabSet.
*/
public function getFrontEndFields($params = null) {
$untabbedFields = $this->scaffoldFormFields($params);
$this->extend('updateFrontEndFields', $untabbedFields);
return $untabbedFields;
}
/**
* Gets the value of a field.
* Called by {@link __get()} and any getFieldName() methods you might create.
*
* @param string $field The name of the field
*
* @return mixed The field value
*/
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];
// Do we have a field that needs to be lazy loaded?
if(isset($this->record[$field.'_Lazy'])) {
$tableClass = $this->record[$field.'_Lazy'];
$this->loadLazyFields($tableClass);
}
// Otherwise, we need to determine if this is a complex field
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];
}
return isset($this->record[$field]) ? $this->record[$field] : null;
}
/**
* Return a map of all the fields for this record
* @deprecated 2.4 Use toMap()
*
* @return array A map of field names to field values.
*/
public function getAllFields() {
Deprecation::notice('3.0', 'Use toMap() instead.');
return $this->toMap();
}
/**
* Loads all the stub fields that an initial lazy load didn't load fully.
*
* @param tableClass Base table to load the values from. Others are joined as required.
* Not specifying a tableClass will load all lazy fields from all tables.
*/
protected function loadLazyFields($tableClass = null) {
if (!$tableClass) {
$loaded = array();
foreach ($this->record as $key => $value) {
if (strlen($key) > 5 && substr($key, -5) == '_Lazy' && !array_key_exists($value, $loaded)) {
$this->loadLazyFields($value);
$loaded[$value] = $value;
}
}
return;
}
$dataQuery = new DataQuery($tableClass);
// TableField sets the record ID to "new" on new row data, so don't try doing anything in that case
if(!is_numeric($this->record['ID'])) return false;
$dataQuery->where("\"$tableClass\".\"ID\" = {$this->record['ID']}")->limit(1);
$columns = array();
// Add SQL for fields, both simple & multi-value
// TODO: This is copy & pasted from buildSQL(), it could be moved into a method
$databaseFields = self::database_fields($tableClass);
if($databaseFields) foreach($databaseFields as $k => $v) {
if(!isset($this->record[$k]) || $this->record[$k] === null) {
$columns[] = $k;
}
}
if ($columns) {
$query = $dataQuery->query(); // eh?
$this->extend('augmentSQL', $query, $dataQuery);
$dataQuery->setQueriedColumns($columns);
$newData = $dataQuery->execute()->record();
// Load the data into record
if($newData) {
foreach($newData as $k => $v) {
if (in_array($k, $columns)) {
$this->record[$k] = $v;
$this->original[$k] = $v;
unset($this->record[$k . '_Lazy']);
}
}
// No data means that the query returned nothing; assign 'null' to all the requested fields
} else {
foreach($columns as $k) {
$this->record[$k] = null;
$this->original[$k] = null;
unset($this->record[$k . '_Lazy']);
}
}
}
}
/**
* Return the fields that have changed.
*
* The change level affects what the functions defines as "changed":
* - Level 1 will return strict changes, even !== ones.
* - Level 2 is more lenient, it will only return real data changes, for example a change from 0 to null
* would not be included.
*
* Example return:
* <code>
* array(
* 'Title' = array('before' => 'Home', 'after' => 'Home-Changed', 'level' => 2)
* )
* </code>
*
* @param boolean $databaseFieldsOnly Get only database fields that have changed
* @param int $changeLevel The strictness of what is defined as change
* @return array
*/
public function getChangedFields($databaseFieldsOnly = false, $changeLevel = 1) {
$changedFields = array();
// Update the changed array with references to changed obj-fields
foreach($this->record as $k => $v) {
if(is_object($v) && method_exists($v, 'isChanged') && $v->isChanged()) {
$this->changed[$k] = 2;
}
}
if($databaseFieldsOnly) {
$databaseFields = $this->inheritedDatabaseFields();
$databaseFields['ID'] = true;
$databaseFields['LastEdited'] = true;
$databaseFields['Created'] = true;
$databaseFields['ClassName'] = true;
$fields = array_intersect_key((array)$this->changed, $databaseFields);
} else {
$fields = $this->changed;
}
// Filter the list to those of a certain change level
if($changeLevel > 1) {
if($fields) foreach($fields as $name => $level) {
if($level < $changeLevel) {
unset($fields[$name]);
}
}
}
if($fields) foreach($fields as $name => $level) {
$changedFields[$name] = array(
'before' => array_key_exists($name, $this->original) ? $this->original[$name] : null,
'after' => array_key_exists($name, $this->record) ? $this->record[$name] : null,
'level' => $level
);
}
return $changedFields;
}
/**
* Uses {@link getChangedFields()} to determine if fields have been changed
* since loading them from the database.
*
* @param string $fieldName Name of the database field to check, will check for any if not given
* @param int $changeLevel See {@link getChangedFields()}
* @return boolean
*/
function isChanged($fieldName = null, $changeLevel = 1) {
$changed = $this->getChangedFields(false, $changeLevel);
if(!isset($fieldName)) {
return !empty($changed);
}
else {
return array_key_exists($fieldName, $changed);
}
}
/**
* Set the value of the field
* Called by {@link __set()} and any setFieldName() methods you might create.
*
* @param string $fieldName Name of the field
* @param mixed $val New field value
*/
function setField($fieldName, $val) {
// Situation 1: Passing an DBField
if($val instanceof DBField) {
$val->Name = $fieldName;
// 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 2: Passing a literal or non-DBField object
} else {
// If this is a proper database field, we shouldn't be getting non-DBField objects
if(is_object($val) && $this->db($fieldName)) {
user_error('DataObject::setField: passed an object that is not a DBField', E_USER_WARNING);
}
$defaults = $this->stat('defaults');
// if a field is not existing or has strictly changed
if(!isset($this->record[$fieldName]) || $this->record[$fieldName] !== $val) {
// TODO Add check for php-level defaults which are not set in the db
// TODO Add check for hidden input-fields (readonly) which are not set in the db
// At the very least, the type has changed
$this->changed[$fieldName] = 1;
if((!isset($this->record[$fieldName]) && $val) || (isset($this->record[$fieldName]) && $this->record[$fieldName] != $val)) {
// Value has changed as well, not just the type
$this->changed[$fieldName] = 2;
}
// 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;
}
}
}
/**
* Set the value of the field, using a casting object.
* This is useful when you aren't sure that a date is in SQL format, for example.
* setCastedField() can also be used, by forms, to set related data. For example, uploaded images
* can be saved into the Image table.
*
* @param string $fieldName Name of the field
* @param mixed $value New field value
*/
public function setCastedField($fieldName, $val) {
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->saveInto($this);
} else {
$this->$fieldName = $val;
}
}
/**
* Returns true if the given field exists
* in a database column on any of the objects tables,
* or as a dynamic getter with get<fieldName>().
*
* @param string $field Name of the field
* @return boolean True if the given field exists
*/
public function hasField($field) {
return (
array_key_exists($field, $this->record)
|| $this->db($field)
|| (substr($field,-2) == 'ID') && $this->has_one(substr($field,0, -2))
|| $this->hasMethod("get{$field}")
);
}
/**
* Returns true if the given field exists as a database column
*
* @param string $field Name of the field
*
* @return boolean
*/
public function hasDatabaseField($field) {
// Add base fields which are not defined in static $db
static $fixedFields = array(
'ID' => 'Int',
'ClassName' => 'Enum',
'LastEdited' => 'SS_Datetime',
'Created' => 'SS_Datetime',
);
if(isset($fixedFields[$field])) return true;
return array_key_exists($field, $this->inheritedDatabaseFields());
}
/**
* Returns the field type of the given field, if it belongs to this class, and not a parent.
* Note that the field type will not include constructor arguments in round brackets, only the classname.
*
* @param string $field Name of the field
* @return string The field type of the given field
*/
public function hasOwnTableDatabaseField($field) {
// Add base fields which are not defined in static $db
if($field == "ID") return "Int";
if($field == "ClassName" && get_parent_class($this) == "DataObject") return "Enum";
if($field == "LastEdited" && get_parent_class($this) == "DataObject") return "SS_Datetime";
if($field == "Created" && get_parent_class($this) == "DataObject") return "SS_Datetime";
// Add fields from Versioned extension
if($field == 'Version' && $this->hasExtension('Versioned')) {
return 'Int';
}
// get cached fieldmap
$fieldMap = isset(DataObject::$cache_has_own_table_field[$this->class]) ? DataObject::$cache_has_own_table_field[$this->class] : null;
// if no fieldmap is cached, get all fields
if(!$fieldMap) {
$fieldMap = Config::inst()->get($this->class, 'db', Config::UNINHERITED);
// all $db fields on this specific class (no parents)
foreach(self::composite_fields($this->class, false) as $fieldname => $fieldtype) {
$combined_db = singleton($fieldtype)->compositeDatabaseFields();
foreach($combined_db as $name => $type){
$fieldMap[$fieldname.$name] = $type;
}
}
// all has_one relations on this specific class,
// add foreign key
$hasOne = Config::inst()->get($this->class, 'has_one', Config::UNINHERITED);
if($hasOne) foreach($hasOne as $fieldName => $fieldSchema) {
$fieldMap[$fieldName . 'ID'] = "ForeignKey";
}
// set cached fieldmap
DataObject::$cache_has_own_table_field[$this->class] = $fieldMap;
}
// Remove string-based "constructor-arguments" from the DBField definition
if(isset($fieldMap[$field])) {
if(is_string($fieldMap[$field])) return strtok($fieldMap[$field],'(');
else return $fieldMap[$field]['type'];
}
}
/**
* Returns true if given class has its own table. Uses the rules for whether the table should exist rather than
* actually looking in the database.
*
* @param string $dataClass
* @return bool
*/
public static function has_own_table($dataClass) {
if(!is_subclass_of($dataClass,'DataObject')) return false;
if(!isset(DataObject::$cache_has_own_table[$dataClass])) {
if(get_parent_class($dataClass) == 'DataObject') {
DataObject::$cache_has_own_table[$dataClass] = true;
} else {
DataObject::$cache_has_own_table[$dataClass] = Config::inst()->get($dataClass, 'db', Config::UNINHERITED) || Config::inst()->get($dataClass, 'has_one', Config::UNINHERITED);
}
}
return DataObject::$cache_has_own_table[$dataClass];
}
/**
* Returns true if the member is allowed to do the given action.
* See {@link extendedCan()} for a more versatile tri-state permission control.
*
* @param string $perm The permission to be checked, such as 'View'.
* @param Member $member The member whose permissions need checking. Defaults to the currently logged
* in user.
*
* @return boolean True if the the member is allowed to do the given action
*/
function can($perm, $member = null) {
if(!isset($member)) {
$member = Member::currentUser();
}
if(Permission::checkMember($member, "ADMIN")) return true;
if($this->many_many('Can' . $perm)) {
if($this->ParentID && $this->SecurityType == 'Inherit') {
if(!($p = $this->Parent)) {
return false;
}
return $this->Parent->can($perm, $member);
} else {
$permissionCache = $this->uninherited('permissionCache');
$memberID = $member ? $member->ID : 'none';
if(!isset($permissionCache[$memberID][$perm])) {
if($member->ID) {
$groups = $member->Groups();
}
$groupList = implode(', ', $groups->column("ID"));
// TODO Fix relation table hardcoding
$query = new SQLQuery(
"\"Page_Can$perm\".PageID",
array("\"Page_Can$perm\""),
"GroupID IN ($groupList)");
$permissionCache[$memberID][$perm] = $query->execute()->column();
if($perm == "View") {
// TODO Fix relation table hardcoding
$query = new SQLQuery("\"SiteTree\".\"ID\"", array(
"\"SiteTree\"",
"LEFT JOIN \"Page_CanView\" ON \"Page_CanView\".\"PageID\" = \"SiteTree\".\"ID\""
), "\"Page_CanView\".\"PageID\" IS NULL");
$unsecuredPages = $query->execute()->column();
if($permissionCache[$memberID][$perm]) {
$permissionCache[$memberID][$perm] = array_merge($permissionCache[$memberID][$perm], $unsecuredPages);
} else {
$permissionCache[$memberID][$perm] = $unsecuredPages;
}
}
Config::inst()->update($this->class, 'permissionCache', $permissionCache);
}
if($permissionCache[$memberID][$perm]) {
return in_array($this->ID, $permissionCache[$memberID][$perm]);
}
}
} else {
return parent::can($perm, $member);
}
}
/**
* Process tri-state responses from permission-alterting extensions. The extensions are
* expected to return one of three values:
*
* - false: Disallow this permission, regardless of what other extensions say
* - true: Allow this permission, as long as no other extensions return false
* - NULL: Don't affect the outcome
*
* This method itself returns a tri-state value, and is designed to be used like this:
*
* <code>
* $extended = $this->extendedCan('canDoSomething', $member);
* if($extended !== null) return $extended;
* else return $normalValue;
* </code>
*
* @param String $methodName Method on the same object, e.g. {@link canEdit()}
* @param Member|int $member
* @return boolean|null
*/
public function extendedCan($methodName, $member) {
$results = $this->extend($methodName, $member);
if($results && is_array($results)) {
// Remove NULLs
$results = array_filter($results, array($this,'isNotNull'));
// If there are any non-NULL responses, then return the lowest one of them.
// If any explicitly deny the permission, then we don't get access
if($results) return min($results);
}
return null;
}
/**
* Helper functon for extendedCan
*
* @param Mixed $value
* @return boolean
*/
private function isNotNull($value) {
return !is_null($value);
}
/**
* @param Member $member
* @return boolean
*/
public function canView($member = null) {
return Permission::check('ADMIN', 'any', $member);
}
/**
* @param Member $member
* @return boolean
*/
public function canEdit($member = null) {
return Permission::check('ADMIN', 'any', $member);
}
/**
* @param Member $member
* @return boolean
*/
public function canDelete($member = null) {
return Permission::check('ADMIN', 'any', $member);
}
/**
* @todo Should canCreate be a static method?
*
* @param Member $member
* @return boolean
*/
public function canCreate($member = null) {
return Permission::check('ADMIN', 'any', $member);
}
/**
* Debugging used by Debug::show()
*
* @return string HTML data representing this object
*/
public function debug() {
$val = "<h3>Database record: $this->class</h3>\n<ul>\n";
if($this->record) foreach($this->record as $fieldName => $fieldVal) {
$val .= "\t<li>$fieldName: " . Debug::text($fieldVal) . "</li>\n";
}
$val .= "</ul>\n";
return $val;
}
/**
* Return the DBField object that represents the given field.
* This works similarly to obj() with 2 key differences:
* - it still returns an object even when the field has no value.
* - it only matches fields and not methods
* - it matches foreign keys generated by has_one relationships, eg, "ParentID"
*
* @param string $fieldName Name of the field
* @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(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);
// General casting information for items in $db or $casting
} else if($helper = $this->castingHelper($fieldName)) {
$obj = Object::create_from_string($helper, $fieldName);
$obj->setValue($this->$fieldName, $this->record, false);
return $obj;
// Special case for has_one relationships
} else if(preg_match('/ID$/', $fieldName) && $this->has_one(substr($fieldName,0,-2))) {
$val = $this->$fieldName;
return DBField::create_field('ForeignKey', $val, $fieldName, $this);
// Special case for ClassName
} else if($fieldName == 'ClassName') {
$val = get_class($this);
return DBField::create_field('Varchar', $val, $fieldName, $this);
}
}
/**
* Traverses to a DBField referenced by relationships between data objects.
* The path to the related field is specified with dot separated syntax (eg: Parent.Child.Child.FieldName)
*
* @param $fieldPath string
* @return DBField
*/
public function relObject($fieldPath) {
if(strpos($fieldPath, '.') !== false) {
$parts = explode('.', $fieldPath);
$fieldName = array_pop($parts);
// Traverse dot syntax
$component = $this;
foreach($parts as $relation) {
if($component instanceof SS_List) {
if(method_exists($component,$relation)) $component = $component->$relation();
else $component = $component->relation($relation);
} else {
$component = $component->$relation();
}
}
$object = $component->dbObject($fieldName);
} else {
$object = $this->dbObject($fieldPath);
}
if (!($object instanceof DBField) && !($object instanceof DataList)) {
// Todo: come up with a broader range of exception objects to describe differnet kinds of errors programatically
throw new Exception("Unable to traverse to related object field [$fieldPath] on [$this->class]");
}
return $object;
}
/**
* Traverses to a field referenced by relationships between data objects, returning the value
* The path to the related field is specified with dot separated syntax (eg: Parent.Child.Child.FieldName)
*
* @param $fieldPath string
* @return string
*/
public function relField($fieldName) {
$component = $this;
if(strpos($fieldName, '.') !== false) {
$parts = explode('.', $fieldName);
$fieldName = array_pop($parts);
// Traverse dot syntax
foreach($parts as $relation) {
if($component instanceof SS_List) {
if(method_exists($component,$relation)) $component = $component->$relation();
else $component = $component->relation($relation);
} else {
$component = $component->$relation();
}
}
}
if ($component->hasMethod($fieldName)) return $component->$fieldName();
return $component->$fieldName;
}
/**
* Temporary hack to return an association name, based on class, to get around the mangle
* of having to deal with reverse lookup of relationships to determine autogenerated foreign keys.
*
* @return String
*/
public function getReverseAssociation($className) {
if (is_array($this->many_many())) {
$many_many = array_flip($this->many_many());
if (array_key_exists($className, $many_many)) return $many_many[$className];
}
if (is_array($this->has_many())) {
$has_many = array_flip($this->has_many());
if (array_key_exists($className, $has_many)) return $has_many[$className];
}
if (is_array($this->has_one())) {
$has_one = array_flip($this->has_one());
if (array_key_exists($className, $has_one)) return $has_one[$className];
}
return false;
}
/**
* @deprecated 3.0 Use DataList::create and DataList to do your querying
*/
public function buildSQL($filter = "", $sort = "", $limit = "", $join = "", $restrictClasses = true, $having = "") {
Deprecation::notice('3.0', 'Use DataList::create and DataList to do your querying instead.');
return $this->extendedSQL($filter, $sort, $limit, $join, $having);
}
/**
* @deprecated 3.0 Use DataList::create and DataList to do your querying
*/
public function extendedSQL($filter = "", $sort = "", $limit = "", $join = ""){
Deprecation::notice('3.0', 'Use DataList::create and DataList to do your querying instead.');
$dataList = DataObject::get($this->class, $filter, $sort, $join, $limit);
return $dataList->dataQuery()->query();
}
/**
* Return all objects matching the filter
* sub-classes are automatically selected and included
*
* @param string $callerClass The class of objects to be returned
* @param string $filter A filter to be inserted into the WHERE clause.
* @param string|array $sort A sort expression to be inserted into the ORDER BY clause. If omitted, self::$default_sort will be used.
* @param string $join A single join clause. This can be used for filtering, only 1 instance of each DataObject will be returned.
* @param string|array $limit A limit expression to be inserted into the LIMIT clause.
* @param string $containerClass The container class to return the results in.
*
* @return mixed The objects matching the filter, in the class specified by $containerClass
*/
public static function get($callerClass = null, $filter = "", $sort = "", $join = "", $limit = null, $containerClass = 'DataList') {
if($callerClass == null) {
$callerClass = get_called_class();
if($callerClass == 'DataObject') {
throw new \InvalidArgumentException('Call <classname>::get() instead of DataObject::get()');
}
if($filter || $sort || $join || $limit || ($containerClass != 'DataList')) {
throw new \InvalidArgumentException('If calling <classname>::get() then you shouldn\'t pass any other arguments');
}
$result = DataList::create(get_called_class());
$result->setDataModel(DataModel::inst());
return $result;
}
// Todo: Determine if we can deprecate for 3.0.0 and use DI or something instead
// Todo: Make the $containerClass method redundant
if($containerClass != 'DataList') {
Deprecation::notice('3.0', 'DataObject::get() - $containerClass argument is deprecated.', Deprecation::SCOPE_GLOBAL);
}
$result = DataList::create($callerClass)->where($filter)->sort($sort);
if($limit && strpos($limit, ',') !== false) {
$limitArguments = explode(',', $limit);
$result->limit($limitArguments[1],$limitArguments[0]);
} elseif($limit) {
$result->limit($limit);
}
if($join) $result = $result->join($join);
$result->setDataModel(DataModel::inst());
return $result;
}
/**
* @deprecated 3.0 Use DataList::create and DataList to do your querying
*/
public function Aggregate($class = null) {
Deprecation::notice('3.0', 'Call aggregate methods on a DataList directly instead. In templates ' .
'an example of the new syntax is &lt% cached List(Member).max(LastEdited) %&gt instead (check partial-caching.md documentation ' .
'for more details.)');
if($class) {
$list = new DataList($class);
$list->setDataModel(DataModel::inst());
} else if(isset($this)) {
$list = new DataList(get_class($this));
$list->setDataModel($this->model);
}
else throw new InvalidArgumentException("DataObject::aggregate() must be called as an instance method or passed a classname");
return $list;
}
/**
* @deprecated 3.0 Use DataList::create and DataList to do your querying
*/
public function RelationshipAggregate($relationship) {
Deprecation::notice('3.0', 'Call aggregate methods on a relationship directly instead.');
return $this->$relationship();
}
/**
* DataList::create("Table")->where("filter") is the same as singleton("Table")->instance_get("filter")
*
* @deprecated 3.0 Use DataList::create and DataList to do your querying
*
* @param string $filter A filter to be inserted into the WHERE clause.
* @param string $sort A sort expression to be inserted into the ORDER BY clause. If omitted, self::$default_sort will be used.
* @param string $join A single join clause. This can be used for filtering, only 1 instance of each DataObject will be returned.
* @param string $limit A limit expression to be inserted into the LIMIT clause.
* @param string $containerClass The container class to return the results in.
*
* @return mixed The objects matching the filter, in the class specified by $containerClass
*/
public function instance_get($filter = "", $sort = "", $join = "", $limit="", $containerClass = "DataObjectSet") {
Deprecation::notice('3.0', 'Use DataList::create and DataList to do your querying instead.');
return self::get($this->class, $filter, $sort, $join, $limit, $containerClass);
}
/**
* Take a database {@link SS_Query} and instanciate an object for each record.
*
* @deprecated 3.0 Replaced by DataList
*
* @param SS_Query|array $records The database records, a {@link SS_Query} object or an array of maps.
* @param string $containerClass The class to place all of the objects into.
*
* @return mixed The new objects in an object of type $containerClass
*/
function buildDataObjectSet($records, $containerClass = "DataObjectSet", $query = null, $baseClass = null) {
Deprecation::notice('3.0', 'Use DataList instead.');
foreach($records as $record) {
if(empty($record['RecordClassName'])) {
$record['RecordClassName'] = $record['ClassName'];
}
if(class_exists($record['RecordClassName'])) {
$results[] = new $record['RecordClassName']($record);
} else {
if(!$baseClass) {
user_error("Bad RecordClassName '{$record['RecordClassName']}' and "
. "\$baseClass not set", E_USER_ERROR);
} else if(!is_string($baseClass) || !class_exists($baseClass)) {
user_error("Bad RecordClassName '{$record['RecordClassName']}' and bad "
. "\$baseClass '$baseClass not set", E_USER_ERROR);
}
$results[] = new $baseClass($record);
}
}
if(isset($results)) {
return new $containerClass($results);
}
}
/**
* Return the first item matching the given query.
* All calls to get_one() are cached.
*
* @param string $callerClass The class of objects to be returned
* @param string $filter A filter to be inserted into the WHERE clause
* @param boolean $cache Use caching
* @param string $orderby A sort expression to be inserted into the ORDER BY clause.
*
* @return DataObject The first item matching the query
*/
public static function get_one($callerClass, $filter = "", $cache = true, $orderby = "") {
$SNG = singleton($callerClass);
$cacheKey = "{$filter}-{$orderby}";
if($extra = $SNG->extend('cacheKeyComponent')) {
$cacheKey .= '-' . implode("-", $extra);
}
$cacheKey = md5($cacheKey);
// Flush destroyed items out of the cache
if($cache && isset(DataObject::$_cache_get_one[$callerClass][$cacheKey]) && DataObject::$_cache_get_one[$callerClass][$cacheKey] instanceof DataObject && DataObject::$_cache_get_one[$callerClass][$cacheKey]->destroyed) {
DataObject::$_cache_get_one[$callerClass][$cacheKey
] = false;
}
if(!$cache || !isset(DataObject::$_cache_get_one[$callerClass][$cacheKey])) {
$dl = DataList::create($callerClass)->where($filter)->sort($orderby);
$dl->setDataModel(DataModel::inst());
$item = $dl->First();
if($cache) {
DataObject::$_cache_get_one[$callerClass][$cacheKey] = $item;
if(!DataObject::$_cache_get_one[$callerClass][$cacheKey]) {
DataObject::$_cache_get_one[$callerClass][$cacheKey] = false;
}
}
}
return $cache ? DataObject::$_cache_get_one[$callerClass][$cacheKey] : $item;
}
/**
* Flush the cached results for all relations (has_one, has_many, many_many)
* Also clears any cached aggregate data.
*
* @param boolean $persistent When true will also clear persistent data stored in the Cache system.
* When false will just clear session-local cached data
*
*/
public function flushCache($persistent = true) {
if($persistent) Aggregate::flushCache($this->class);
if($this->class == 'DataObject') {
DataObject::$_cache_get_one = array();
return;
}
$classes = ClassInfo::ancestry($this->class);
foreach($classes as $class) {
if(isset(DataObject::$_cache_get_one[$class])) unset(DataObject::$_cache_get_one[$class]);
}
$this->extend('flushCache');
$this->components = array();
}
/**
* Flush the get_one global cache and destroy associated objects.
*/
static function flush_and_destroy_cache() {
if(DataObject::$_cache_get_one) foreach(DataObject::$_cache_get_one as $class => $items) {
if(is_array($items)) foreach($items as $item) {
if($item) $item->destroy();
}
}
DataObject::$_cache_get_one = array();
}
/**
* Reset all global caches associated with DataObject.
*/
static function reset() {
DataObject::$cache_has_own_table = array();
DataObject::$cache_has_own_table_field = array();
DataObject::$_cache_get_one = array();
DataObject::$_cache_composite_fields = array();
DataObject::$_cache_get_class_ancestry = array();
DataObject::$_cache_field_labels = array();
}
/**
* Does the hard work for get_one()
*
* @deprecated 3.0 Use DataList::create($this->class)->where($filter)->sort($orderby)->First() instead
*
* @uses DataExtension->augmentSQL()
*
* @param string $filter A filter to be inserted into the WHERE clause
* @param string $orderby A sort expression to be inserted into the ORDER BY clause.
* @return DataObject The first item matching the query
*/
public function instance_get_one($filter, $orderby = null) {
Deprecation::notice('3.0', 'Use DataList::create($this->class)->where($filter)->sort($orderby)->First() instead.');
return DataObject::get_one($this->class, $filter, true, $orderby);
}
/**
* Return the given element, searching by ID
*
* @param string $callerClass The class of the object to be returned
* @param int $id The id of the element
* @param boolean $cache See {@link get_one()}
*
* @return DataObject The element
*/
public static function get_by_id($callerClass, $id, $cache = true) {
if(is_numeric($id)) {
if(is_subclass_of($callerClass, 'DataObject')) {
$baseClass = ClassInfo::baseDataClass($callerClass);
return DataObject::get_one($callerClass,"\"$baseClass\".\"ID\" = $id", $cache);
// This simpler code will be used by non-DataObject classes that implement DataObjectInterface
} else {
return DataObject::get_one($callerClass,"\"ID\" = $id", $cache);
}
} else {
user_error("DataObject::get_by_id passed a non-numeric ID #$id", E_USER_WARNING);
}
}
/**
* Get the name of the base table for this object
*/
public function baseTable() {
$tableClasses = ClassInfo::dataClassesFor($this->class);
return array_shift($tableClasses);
}
//-------------------------------------------------------------------------------------------//
/**
* Return the database indexes on this table.
* This array is indexed by the name of the field with the index, and
* the value is the type of index.
*/
public function databaseIndexes() {
$has_one = $this->uninherited('has_one',true);
$classIndexes = $this->uninherited('indexes',true);
//$fileIndexes = $this->uninherited('fileIndexes', true);
$indexes = array();
if($has_one) {
foreach($has_one as $relationshipName => $fieldType) {
$indexes[$relationshipName . 'ID'] = true;
}
}
if($classIndexes) {
foreach($classIndexes as $indexName => $indexType) {
$indexes[$indexName] = $indexType;
}
}
if(get_parent_class($this) == "DataObject") {
$indexes['ClassName'] = true;
}
return $indexes;
}
/**
* Check the database schema and update it as necessary.
*
* @uses DataExtension->augmentDatabase()
*/
public function requireTable() {
// Only build the table if we've actually got fields
$fields = self::database_fields($this->class);
$extensions = self::database_extensions($this->class);
$indexes = $this->databaseIndexes();
if($fields) {
$hasAutoIncPK = ($this->class == ClassInfo::baseDataClass($this->class));
DB::requireTable($this->class, $fields, $indexes, $hasAutoIncPK, $this->stat('create_table_options'), $extensions);
} else {
DB::dontRequireTable($this->class);
}
// Build any child tables for many_many items
if($manyMany = $this->uninherited('many_many', true)) {
$extras = $this->uninherited('many_many_extraFields', true);
foreach($manyMany as $relationship => $childClass) {
// Build field list
$manymanyFields = array(
"{$this->class}ID" => "Int",
(($this->class == $childClass) ? "ChildID" : "{$childClass}ID") => "Int",
);
if(isset($extras[$relationship])) {
$manymanyFields = array_merge($manymanyFields, $extras[$relationship]);
}
// Build index list
$manymanyIndexes = array(
"{$this->class}ID" => true,
(($this->class == $childClass) ? "ChildID" : "{$childClass}ID") => true,
);
DB::requireTable("{$this->class}_$relationship", $manymanyFields, $manymanyIndexes, true, null, $extensions);
}
}
// Let any extentions make their own database fields
$this->extend('augmentDatabase', $dummy);
}
/**
* Add default records to database. This function is called whenever the
* database is built, after the database tables have all been created. Overload
* this to add default records when the database is built, but make sure you
* call parent::requireDefaultRecords().
*
* @uses DataExtension->requireDefaultRecords()
*/
public function requireDefaultRecords() {
$defaultRecords = $this->stat('default_records');
if(!empty($defaultRecords)) {
$hasData = DataObject::get_one($this->class);
if(!$hasData) {
$className = $this->class;
foreach($defaultRecords as $record) {
$obj = $this->model->$className->newObject($record);
$obj->write();
}
DB::alteration_message("Added default records to $className table","created");
}
}
// Let any extentions make their own database default data
$this->extend('requireDefaultRecords', $dummy);
}
/**
* @deprecated 3.0 Use DataObject::database_fields() instead
* @see DataObject::database_fields()
*/
public function databaseFields() {
Deprecation::notice('3.0', 'Use DataObject::database_fields() instead.');
return self::database_fields($this->class);
}
/**
* @deprecated 3.0 Use DataObject::custom_database_fields() instead
* @see DataObject::custom_database_fields()
*/
public function customDatabaseFields() {
Deprecation::notice('3.0', 'Use DataObject::custom_database_fields() instead.');
return self::custom_database_fields($this->class);
}
/**
* Returns fields bu traversing the class heirachy in a bottom-up direction.
*
* Needed to avoid getCMSFields being empty when customDatabaseFields overlooks
* the inheritance chain of the $db array, where a child data object has no $db array,
* but still needs to know the properties of its parent. This should be merged into databaseFields or
* customDatabaseFields.
*
* @todo review whether this is still needed after recent API changes
*/
public function inheritedDatabaseFields() {
$fields = array();
$currentObj = $this->class;
while($currentObj != 'DataObject') {
$fields = array_merge($fields, self::custom_database_fields($currentObj));
$currentObj = get_parent_class($currentObj);
}
return (array) $fields;
}
/**