Extensions * * See {@link Extension} and {@link DataExtension}. * *
* class Article extends DataObject implements PermissionProvider {
* static $api_access = true;
*
* function canView($member = false) {
* return Permission::check('ARTICLE_VIEW');
* }
* function canEdit($member = false) {
* return Permission::check('ARTICLE_EDIT');
* }
* function canDelete() {
* return Permission::check('ARTICLE_DELETE');
* }
* function canCreate() {
* return Permission::check('ARTICLE_CREATE');
* }
* 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',
* );
* }
* }
*
*
* Object-level access control by {@link Group} membership:
*
* class Article extends DataObject {
* static $api_access = true;
*
* function canView($member = false) {
* if(!$member) $member = Security::getCurrentUser();
* return $member->inGroup('Subscribers');
* }
* function canEdit($member = false) {
* if(!$member) $member = Security::getCurrentUser();
* return $member->inGroup('Editors');
* }
*
* // ...
* }
*
*
* 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()
*
* @property int $ID ID of the DataObject, 0 if the DataObject doesn't exist in database.
* @property int $OldID ID of object, if deleted
* @property string $ClassName Class name of the DataObject
* @property string $LastEdited Date and time of DataObject's last modification.
* @property string $Created Date and time of DataObject creation.
*/
class DataObject extends ViewableData implements DataObjectInterface, i18nEntityProvider, Resettable
{
/**
* Human-readable singular name.
* @var string
* @config
*/
private static $singular_name = null;
/**
* Human-readable plural name
* @var string
* @config
*/
private static $plural_name = null;
/**
* Allow API access to this object?
* @todo Define the options that can be set here
* @config
*/
private static $api_access = false;
/**
* Allows specification of a default value for the ClassName field.
* Configure this value only in subclasses of DataObject.
*
* @config
* @var string
*/
private static $default_classname = null;
/**
* @deprecated 4.0..5.0
* @var bool
*/
public $destroyed = false;
/**
* 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;
/**
* If selected through a many_many through relation, this is the instance of the through record
*
* @var DataObject
*/
protected $joinRecord;
/**
* Represents a field that hasn't changed (before === after, thus before == after)
*/
const CHANGE_NONE = 0;
/**
* Represents a field that has changed type, although not the loosely defined value.
* (before !== after && before == after)
* E.g. change 1 to true or "true" to true, but not true to 0.
* Value changes are by nature also considered strict changes.
*/
const CHANGE_STRICT = 1;
/**
* Represents a field that has changed the loosely defined value
* (before != after, thus, before !== after))
* E.g. change false to true, but not false to 0
*/
const CHANGE_VALUE = 2;
/**
* 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;
/**
* @config
* @var boolean Should dataobjects be validated before they are written?
* Caution: Validation can contain safeguards against invalid/malicious data,
* and check permission levels (e.g. on {@link Group}). Therefore it is recommended
* to only disable validation for very specific use cases.
*/
private static $validation_enabled = true;
/**
* Static caches used by relevant functions.
*
* @var array
*/
protected static $_cache_get_one;
/**
* Cache of field labels
*
* @var array
*/
protected static $_cache_field_labels = array();
/**
* Base fields which are not defined in static $db
*
* @config
* @var array
*/
private static $fixed_fields = array(
'ID' => 'PrimaryKey',
'ClassName' => 'DBClassName',
'LastEdited' => 'DBDatetime',
'Created' => 'DBDatetime',
);
/**
* Override table name for this class. If ignored will default to FQN of class.
* This option is not inheritable, and must be set on each class.
* If left blank naming will default to the legacy (3.x) behaviour.
*
* @var string
*/
private static $table_name = null;
/**
* Non-static relationship cache, indexed by component name.
*
* @var DataObject[]
*/
protected $components;
/**
* Non-static cache of has_many and many_many relations that can't be written until this object is saved.
*
* @var UnsavedRelationList[]
*/
protected $unsavedRelations;
/**
* List of relations that should be cascade deleted, similar to `owns`
* Note: This will trigger delete on many_many objects, not only the mapping table.
* For many_many through you can specify the components you want to delete separately
* (many_many or has_many sub-component)
*
* @config
* @var array
*/
private static $cascade_deletes = [];
/**
* Get schema object
*
* @return DataObjectSchema
*/
public static function getSchema()
{
return Injector::inst()->get(DataObjectSchema::class);
}
/**
* Construct a new DataObject.
*
* @param array|null $record Used internally for rehydrating an object from database content.
* Bypasses setters on this class, and hence should not be used
* for populating data on new records.
* @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.
* @param array $queryParams List of DataQuery params necessary to lazy load, or load related objects.
*/
public function __construct($record = null, $isSingleton = false, $queryParams = array())
{
parent::__construct();
// Set query params on the DataObject to tell the lazy loading mechanism the context the object creation context
$this->setSourceQueryParams($queryParams);
// Set the fields data.
if (!$record) {
$record = array(
'ID' => 0,
'ClassName' => static::class,
'RecordClassName' => static::class
);
}
if ($record instanceof stdClass) {
$record = (array)$record;
}
if (!is_array($record)) {
if (is_object($record)) {
$passed = "an object of type '" . get_class($record) . "'";
} 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'])) {
// Get all field specs scoped to class for later lazy loading
$fields = static::getSchema()->fieldSpecs(
static::class,
DataObjectSchema::INCLUDE_CLASS | DataObjectSchema::DB_ONLY
);
foreach ($fields as $field => $fieldSpec) {
$fieldClass = strtok($fieldSpec, ".");
if (!array_key_exists($field, $record)) {
$this->record[$field . '_Lazy'] = $fieldClass;
}
}
}
$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();
}
/**
* 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.
*/
public function destroy()
{
$this->flushCache(false);
}
/**
* Create a duplicate of this node. Can duplicate many_many relations
*
* @param bool $doWrite Perform a write() operation before returning the object.
* If this is true, it will create the duplicate in the database.
* @param bool|string $manyMany Which many-many to duplicate. Set to true to duplicate all, false to duplicate none.
* Alternatively set to the string of the relation config to duplicate
* (supports 'many_many', or 'belongs_many_many')
* @return static A duplicate of this node. The exact type will be the type of this node.
*/
public function duplicate($doWrite = true, $manyMany = 'many_many')
{
$map = $this->toMap();
unset($map['Created']);
/** @var static $clone */
$clone = Injector::inst()->create(static::class, $map, false, $this->getSourceQueryParams());
$clone->ID = 0;
$clone->invokeWithExtensions('onBeforeDuplicate', $this, $doWrite, $manyMany);
if ($manyMany) {
$this->duplicateManyManyRelations($this, $clone, $manyMany);
}
if ($doWrite) {
$clone->write();
}
$clone->invokeWithExtensions('onAfterDuplicate', $this, $doWrite, $manyMany);
return $clone;
}
/**
* Copies the many_many and belongs_many_many relations from one object to another instance of the name of object.
*
* @param DataObject $sourceObject the source object to duplicate from
* @param DataObject $destinationObject the destination object to populate with the duplicated relations
* @param bool|string $filter
*/
protected function duplicateManyManyRelations($sourceObject, $destinationObject, $filter)
{
// Get list of relations to duplicate
if ($filter === 'many_many' || $filter === 'belongs_many_many') {
$relations = $sourceObject->config()->get($filter);
} elseif ($filter === true) {
$relations = $sourceObject->manyMany();
} else {
throw new InvalidArgumentException("Invalid many_many duplication filter");
}
foreach ($relations as $manyManyName => $type) {
$this->duplicateManyManyRelation($sourceObject, $destinationObject, $manyManyName);
}
}
/**
* Duplicates a single many_many relation from one object to another
*
* @param DataObject $sourceObject
* @param DataObject $destinationObject
* @param string $manyManyName
*/
protected function duplicateManyManyRelation($sourceObject, $destinationObject, $manyManyName)
{
// Ensure this component exists on the destination side as well
if (!static::getSchema()->manyManyComponent(get_class($destinationObject), $manyManyName)) {
return;
}
// Copy all components from source to destination
$source = $sourceObject->getManyManyComponents($manyManyName);
$dest = $destinationObject->getManyManyComponents($manyManyName);
foreach ($source as $item) {
$dest->add($item);
}
}
/**
* Return obsolete class name, if this is no longer a valid class
*
* @return string
*/
public function getObsoleteClassName()
{
$className = $this->getField("ClassName");
if (!ClassInfo::exists($className)) {
return $className;
}
return null;
}
/**
* Gets name of this class
*
* @return string
*/
public function getClassName()
{
$className = $this->getField("ClassName");
if (!ClassInfo::exists($className)) {
return static::class;
}
return $className;
}
/**
* 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})
* @return $this
*/
public function setClassName($className)
{
$className = trim($className);
if (!$className || !is_subclass_of($className, self::class)) {
return $this;
}
$this->setField("ClassName", $className);
$this->setField('RecordClassName', $className);
return $this;
}
/**
* 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.
*/
public function newClassInstance($newClassName)
{
if (!is_subclass_of($newClassName, self::class)) {
throw new InvalidArgumentException("$newClassName is not a valid subclass of DataObject");
}
$originalClass = $this->ClassName;
/** @var DataObject $newInstance */
$newInstance = Injector::inst()->create($newClassName, $this->record, false);
// Modify ClassName
if ($newClassName != $originalClass) {
$newInstance->setClassName($newClassName);
$newInstance->populateDefaults();
$newInstance->forceChange();
}
return $newInstance;
}
/**
* Adds methods from the extensions.
* Called by Object::__construct() once per class.
*/
public function defineMethods()
{
parent::defineMethods();
if (static::class === self::class) {
return;
}
// Set up accessors for joined items
if ($manyMany = $this->manyMany()) {
foreach ($manyMany as $relationship => $class) {
$this->addWrapperMethod($relationship, 'getManyManyComponents');
}
}
if ($hasMany = $this->hasMany()) {
foreach ($hasMany as $relationship => $class) {
$this->addWrapperMethod($relationship, 'getComponents');
}
}
if ($hasOne = $this->hasOne()) {
foreach ($hasOne as $relationship => $class) {
$this->addWrapperMethod($relationship, 'getComponent');
}
}
if ($belongsTo = $this->belongsTo()) {
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 (isset($this->record['ID']) && $this->record['ID'] > 0);
}
/**
* Returns TRUE if all values (other than "ID") are
* considered empty (by weak boolean comparison).
*
* @return boolean
*/
public function isEmpty()
{
$fixed = DataObject::config()->uninherited('fixed_fields');
foreach ($this->toMap() as $field => $value) {
// only look at custom fields
if (isset($fixed[$field])) {
continue;
}
$dbObject = $this->dbObject($field);
if (!$dbObject) {
continue;
}
if ($dbObject->exists()) {
return false;
}
}
return true;
}
/**
* Pluralise this item given a specific count.
*
* E.g. "0 Pages", "1 File", "3 Images"
*
* @param string $count
* @return string
*/
public function i18n_pluralise($count)
{
$default = 'one ' . $this->i18n_singular_name() . '|{count} ' . $this->i18n_plural_name();
return i18n::_t(
static::class . '.PLURALS',
$default,
[ 'count' => $count ]
);
}
/**
* 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
*/
public function singular_name()
{
$name = $this->config()->get('singular_name');
if ($name) {
return $name;
}
return ucwords(trim(strtolower(preg_replace(
'/_?([A-Z])/',
' $1',
ClassInfo::shortName($this)
))));
}
/**
* 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
*/
public function i18n_singular_name()
{
return _t(static::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
*/
public function plural_name()
{
if ($name = $this->config()->get('plural_name')) {
return $name;
}
$name = $this->singular_name();
//if the penultimate character is not a vowel, replace "y" with "ies"
if (preg_match('/[^aeiou]y$/i', $name)) {
$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
*/
public function i18n_plural_name()
{
return _t(static::class . '.PLURALNAME', $this->plural_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:
*
* function getTitle() {
* return "{$this->StreetNumber} {$this->StreetName} {$this->City}";
* }
*
*
* @return string
*/
public function getTitle()
{
$schema = static::getSchema();
if ($schema->fieldSpec($this, 'Title')) {
return $this->getField('Title');
}
if ($schema->fieldSpec($this, '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;
}
/**
* Return all currently fetched database fields.
*
* This function is similar to toMap() but doesn't trigger the lazy-loading of all unfetched fields.
* Obviously, this makes it a lot faster.
*
* @return array The data as a map.
*/
public function getQueriedDatabaseFields()
{
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.
* @return DataObject $this
*/
public function update($data)
{
foreach ($data as $key => $value) {
// Implement dot syntax for updates
if (strpos($key, '.') !== false) {
$relations = explode('.', $key);
$fieldName = array_pop($relations);
/** @var static $relObj */
$relObj = $this;
$relation = null;
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) {
$parentObj = $relObj;
$relObj = $relObj->$relation();
// If the intermediate relationship objects haven't been created, then write them
if ($i
* class MyCustomClass extends DataObject {
* static $db = array('CustomProperty'=>'Boolean');
*
* function getCMSFields() {
* $fields = parent::getCMSFields();
* $fields->addFieldToTab('Root.Content',new CheckboxField('CustomProperty'));
* return $fields;
* }
* }
*
*
* @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 FieldList 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;
}
public function getViewerTemplates($suffix = '')
{
return SSViewer::get_templates_by_class(static::class, $suffix, $this->baseClass());
}
/**
* 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);
}
// In case of complex fields, return the DBField object
if (static::getSchema()->compositeField(static::class, $field)) {
$this->record[$field] = $this->dbObject($field);
}
return isset($this->record[$field]) ? $this->record[$field] : null;
}
/**
* Loads all the stub fields that an initial lazy load didn't load fully.
*
* @param string $class Class to load the values from. Others are joined as required.
* Not specifying a tableClass will load all lazy fields from all tables.
* @return bool Flag if lazy loading succeeded
*/
protected function loadLazyFields($class = null)
{
if (!$this->isInDB() || !is_numeric($this->ID)) {
return false;
}
if (!$class) {
$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 false;
}
$dataQuery = new DataQuery($class);
// Reset query parameter context to that of this DataObject
if ($params = $this->getSourceQueryParams()) {
foreach ($params as $key => $value) {
$dataQuery->setQueryParam($key, $value);
}
}
// Limit query to the current record, unless it has the Versioned extension,
// in which case it requires special handling through augmentLoadLazyFields()
$schema = static::getSchema();
$baseIDColumn = $schema->sqlColumnForField($this, 'ID');
$dataQuery->where([
$baseIDColumn => $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 = $schema->databaseFields($class, false);
foreach ($databaseFields as $k => $v) {
if (!isset($this->record[$k]) || $this->record[$k] === null) {
$columns[] = $k;
}
}
if ($columns) {
$query = $dataQuery->query();
$this->extend('augmentLoadLazyFields', $query, $dataQuery, $this);
$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 true;
}
/**
* Return the fields that have changed.
*
* The change level affects what the functions defines as "changed":
* - Level CHANGE_STRICT (integer 1) will return strict changes, even !== ones.
* - Level CHANGE_VALUE (integer 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:
*
* array(
* 'Title' = array('before' => 'Home', 'after' => 'Home-Changed', 'level' => DataObject::CHANGE_VALUE)
* )
*
*
* @param boolean|array $databaseFieldsOnly Filter to determine which fields to return. Set to true
* to return all database fields, or an array for an explicit filter. false returns all fields.
* @param int $changeLevel The strictness of what is defined as change. Defaults to strict
* @return array
*/
public function getChangedFields($databaseFieldsOnly = false, $changeLevel = self::CHANGE_STRICT)
{
$changedFields = array();
// Update the changed array with references to changed obj-fields
foreach ($this->record as $k => $v) {
// Prevents DBComposite infinite looping on isChanged
if (is_array($databaseFieldsOnly) && !in_array($k, $databaseFieldsOnly)) {
continue;
}
if (is_object($v) && method_exists($v, 'isChanged') && $v->isChanged()) {
$this->changed[$k] = self::CHANGE_VALUE;
}
}
if (is_array($databaseFieldsOnly)) {
$fields = array_intersect_key((array)$this->changed, array_flip($databaseFieldsOnly));
} elseif ($databaseFieldsOnly) {
$fieldsSpecs = static::getSchema()->fieldSpecs(static::class);
$fields = array_intersect_key((array)$this->changed, $fieldsSpecs);
} else {
$fields = $this->changed;
}
// Filter the list to those of a certain change level
if ($changeLevel > self::CHANGE_STRICT) {
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
*/
public function isChanged($fieldName = null, $changeLevel = self::CHANGE_STRICT)
{
$fields = $fieldName ? array($fieldName) : true;
$changed = $this->getChangedFields($fields, $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
* @return $this
*/
public function setField($fieldName, $val)
{
$this->objCacheClear();
//if it's a has_one component, destroy the cache
if (substr($fieldName, -2) == 'ID') {
unset($this->components[substr($fieldName, 0, -2)]);
}
// If we've just lazy-loaded the column, then we need to populate the $original array
if (isset($this->record[$fieldName . '_Lazy'])) {
$tableClass = $this->record[$fieldName . '_Lazy'];
$this->loadLazyFields($tableClass);
}
// Situation 1: Passing an DBField
if ($val instanceof DBField) {
$val->setName($fieldName);
$val->saveInto($this);
// Situation 1a: Composite fields should remain bound in case they are
// later referenced to update the parent dataobject
if ($val instanceof DBComposite) {
$val->bindTo($this);
$this->record[$fieldName] = $val;
}
// Situation 2: Passing a literal or non-DBField object
} else {
// If this is a proper database field, we shouldn't be getting non-DBField objects
if (is_object($val) && static::getSchema()->fieldSpec(static::class, $fieldName)) {
throw new InvalidArgumentException('DataObject::setField: passed an object that is not a DBField');
}
// 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] = self::CHANGE_STRICT;
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] = self::CHANGE_VALUE;
}
// Value is always saved back when strict check succeeds.
$this->record[$fieldName] = $val;
}
}
return $this;
}
/**
* 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
* @return $this
*/
public function setCastedField($fieldName, $value)
{
if (!$fieldName) {
user_error("DataObject::setCastedField: Called without a fieldName", E_USER_ERROR);
}
$fieldObj = $this->dbObject($fieldName);
if ($fieldObj) {
$fieldObj->setValue($value);
$fieldObj->saveInto($this);
} else {
$this->$fieldName = $value;
}
return $this;
}
/**
* {@inheritdoc}
*/
public function castingHelper($field)
{
$fieldSpec = static::getSchema()->fieldSpec(static::class, $field);
if ($fieldSpec) {
return $fieldSpec;
}
// many_many_extraFields aren't presented by db(), so we check if the source query params
// provide us with meta-data for a many_many relation we can inspect for extra fields.
$queryParams = $this->getSourceQueryParams();
if (!empty($queryParams['Component.ExtraFields'])) {
$extraFields = $queryParams['Component.ExtraFields'];
if (isset($extraFields[$field])) {
return $extraFields[$field];
}
}
return parent::castingHelper($field);
}
/**
* Returns true if the given field exists in a database column on any of
* the objects tables and optionally look up a dynamic getter with
* get
* $extended = $this->extendedCan('canDoSomething', $member);
* if($extended !== null) return $extended;
* else return $normalValue;
*
*
* @param string $methodName Method on the same object, e.g. {@link canEdit()}
* @param Member|int $member
* @param array $context Optional context
* @return boolean|null
*/
public function extendedCan($methodName, $member, $context = array())
{
$results = $this->extend($methodName, $member, $context);
if ($results && is_array($results)) {
// Remove NULLs
$results = array_filter($results, function ($v) {
return !is_null($v);
});
// 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;
}
/**
* @param Member $member
* @return boolean
*/
public function canView($member = null)
{
$extended = $this->extendedCan(__FUNCTION__, $member);
if ($extended !== null) {
return $extended;
}
return Permission::check('ADMIN', 'any', $member);
}
/**
* @param Member $member
* @return boolean
*/
public function canEdit($member = null)
{
$extended = $this->extendedCan(__FUNCTION__, $member);
if ($extended !== null) {
return $extended;
}
return Permission::check('ADMIN', 'any', $member);
}
/**
* @param Member $member
* @return boolean
*/
public function canDelete($member = null)
{
$extended = $this->extendedCan(__FUNCTION__, $member);
if ($extended !== null) {
return $extended;
}
return Permission::check('ADMIN', 'any', $member);
}
/**
* @param Member $member
* @param array $context Additional context-specific data which might
* affect whether (or where) this object could be created.
* @return boolean
*/
public function canCreate($member = null, $context = array())
{
$extended = $this->extendedCan(__FUNCTION__, $member, $context);
if ($extended !== null) {
return $extended;
}
return Permission::check('ADMIN', 'any', $member);
}
/**
* Debugging used by Debug::show()
*
* @return string HTML data representing this object
*/
public function debug()
{
$class = static::class;
$val = "
* array(
* 'MySQLDatabase' => 'ENGINE=MyISAM'
* )
*
*
* Caution: This API is experimental, and might not be
* included in the next major release. Please use with care.
*
* @var array
* @config
*/
private static $create_table_options = array(
MySQLSchemaManager::ID => 'ENGINE=InnoDB'
);
/**
* If a field is in this array, then create a database index
* on that field. This is a map from fieldname to index type.
* See {@link SS_Database->requireIndex()} and custom subclasses for details on the array notation.
*
* @var array
* @config
*/
private static $indexes = null;
/**
* Inserts standard column-values when a DataObject
* is instanciated. Does not insert default records {@see $default_records}.
* This is a map from fieldname to default value.
*
* - If you would like to change a default value in a sub-class, just specify it.
* - If you would like to disable the default value given by a parent class, set the default value to 0,'',
* or false in your subclass. Setting it to null won't work.
*
* @var array
* @config
*/
private static $defaults = [];
/**
* Multidimensional array which inserts default data into the database
* on a db/build-call as long as the database-table is empty. Please use this only
* for simple constructs, not for SiteTree-Objects etc. which need special
* behaviour such as publishing and ParentNodes.
*
* Example:
* array(
* array('Title' => "DefaultPage1", 'PageTitle' => 'page1'),
* array('Title' => "DefaultPage2")
* ).
*
* @var array
* @config
*/
private static $default_records = null;
/**
* One-to-zero relationship defintion. This is a map of component name to data type. In order to turn this into a
* true one-to-one relationship you can add a {@link DataObject::$belongs_to} relationship on the child class.
*
* Note that you cannot have a has_one and belongs_to relationship with the same name.
*
* @var array
* @config
*/
private static $has_one = [];
/**
* A meta-relationship that allows you to define the reverse side of a {@link DataObject::$has_one}.
*
* This does not actually create any data structures, but allows you to query the other object in a one-to-one
* relationship from the child object. If you have multiple belongs_to links to another object you can use the
* syntax "ClassName.HasOneName" to specify which foreign has_one key on the other object to use.
*
* Note that you cannot have a has_one and belongs_to relationship with the same name.
*
* @var array
* @config
*/
private static $belongs_to = [];
/**
* This defines a one-to-many relationship. It is a map of component name to the remote data class.
*
* This relationship type does not actually create a data structure itself - you need to define a matching $has_one
* relationship on the child class. Also, if the $has_one relationship on the child class has multiple links to this
* class you can use the syntax "ClassName.HasOneRelationshipName" in the remote data class definition to show
* which foreign key to use.
*
* @var array
* @config
*/
private static $has_many = [];
/**
* many-many relationship definitions.
* This is a map from component name to data type.
* @var array
* @config
*/
private static $many_many = [];
/**
* Extra fields to include on the connecting many-many table.
* This is a map from field name to field type.
*
* Example code:
*
* public static $many_many_extraFields = array(
* 'Members' => array(
* 'Role' => 'Varchar(100)'
* )
* );
*
*
* @var array
* @config
*/
private static $many_many_extraFields = [];
/**
* The inverse side of a many-many relationship.
* This is a map from component name to data type.
* @var array
* @config
*/
private static $belongs_many_many = [];
/**
* The default sort expression. This will be inserted in the ORDER BY
* clause of a SQL query if no other sort expression is provided.
* @var string
* @config
*/
private static $default_sort = null;
/**
* Default list of fields that can be scaffolded by the ModelAdmin
* search interface.
*
* Overriding the default filter, with a custom defined filter:
*
* static $searchable_fields = array(
* "Name" => "PartialMatchFilter"
* );
*
*
* Overriding the default form fields, with a custom defined field.
* The 'filter' parameter will be generated from {@link DBField::$default_search_filter_class}.
* The 'title' parameter will be generated from {@link DataObject->fieldLabels()}.
*
* static $searchable_fields = array(
* "Name" => array(
* "field" => "TextField"
* )
* );
*
*
* Overriding the default form field, filter and title:
*
* static $searchable_fields = array(
* "Organisation.ZipCode" => array(
* "field" => "TextField",
* "filter" => "PartialMatchFilter",
* "title" => 'Organisation ZIP'
* )
* );
*
* @config
*/
private static $searchable_fields = null;
/**
* User defined labels for searchable_fields, used to override
* default display in the search form.
* @config
*/
private static $field_labels = [];
/**
* Provides a default list of fields to be used by a 'summary'
* view of this object.
* @config
*/
private static $summary_fields = [];
public function provideI18nEntities()
{
// Note: see http://guides.rubyonrails.org/i18n.html#pluralization for rules
// Best guess for a/an rule. Better guesses require overriding in subclasses
$pluralName = $this->plural_name();
$singularName = $this->singular_name();
$conjunction = preg_match('/^[aeiou]/i', $singularName) ? 'An ' : 'A ';
return [
static::class . '.SINGULARNAME' => $this->singular_name(),
static::class . '.PLURALNAME' => $pluralName,
static::class . '.PLURALS' => [
'one' => $conjunction . $singularName,
'other' => '{count} ' . $pluralName
]
];
}
/**
* Returns true if the given method/parameter has a value
* (Uses the DBField::hasValue if the parameter is a database field)
*
* @param string $field The field name
* @param array $arguments
* @param bool $cache
* @return boolean
*/
public function hasValue($field, $arguments = null, $cache = true)
{
// has_one fields should not use dbObject to check if a value is given
$hasOne = static::getSchema()->hasOneComponent(static::class, $field);
if (!$hasOne && ($obj = $this->dbObject($field))) {
return $obj->exists();
} else {
return parent::hasValue($field, $arguments, $cache);
}
}
/**
* If selected through a many_many through relation, this is the instance of the joined record
*
* @return DataObject
*/
public function getJoin()
{
return $this->joinRecord;
}
/**
* Set joining object
*
* @param DataObject $object
* @param string $alias Alias
* @return $this
*/
public function setJoin(DataObject $object, $alias = null)
{
$this->joinRecord = $object;
if ($alias) {
if (static::getSchema()->fieldSpec(static::class, $alias)) {
throw new InvalidArgumentException(
"Joined record $alias cannot also be a db field"
);
}
$this->record[$alias] = $object;
}
return $this;
}
/**
* Find objects in the given relationships, merging them into the given list
*
* @param string $source Config property to extract relationships from
* @param bool $recursive True if recursive
* @param ArrayList $list If specified, items will be added to this list. If not, a new
* instance of ArrayList will be constructed and returned
* @return ArrayList The list of related objects
*/
public function findRelatedObjects($source, $recursive = true, $list = null)
{
if (!$list) {
$list = new ArrayList();
}
// Skip search for unsaved records
if (!$this->isInDB()) {
return $list;
}
$relationships = $this->config()->get($source) ?: [];
foreach ($relationships as $relationship) {
// Warn if invalid config
if (!$this->hasMethod($relationship)) {
trigger_error(sprintf(
"Invalid %s config value \"%s\" on object on class \"%s\"",
$source,
$relationship,
get_class($this)
), E_USER_WARNING);
continue;
}
// Inspect value of this relationship
$items = $this->{$relationship}();
// Merge any new item
$newItems = $this->mergeRelatedObjects($list, $items);
// Recurse if necessary
if ($recursive) {
foreach ($newItems as $item) {
/** @var DataObject $item */
$item->findRelatedObjects($source, true, $list);
}
}
}
return $list;
}
/**
* Helper method to merge owned/owning items into a list.
* Items already present in the list will be skipped.
*
* @param ArrayList $list Items to merge into
* @param mixed $items List of new items to merge
* @return ArrayList List of all newly added items that did not already exist in $list
*/
public function mergeRelatedObjects($list, $items)
{
$added = new ArrayList();
if (!$items) {
return $added;
}
if ($items instanceof DataObject) {
$items = [$items];
}
/** @var DataObject $item */
foreach ($items as $item) {
$this->mergeRelatedObject($list, $added, $item);
}
return $added;
}
/**
* Merge single object into a list, but ensures that existing objects are not
* re-added.
*
* @param ArrayList $list Global list
* @param ArrayList $added Additional list to insert into
* @param DataObject $item Item to add
*/
protected function mergeRelatedObject($list, $added, $item)
{
// Identify item
$itemKey = get_class($item) . '/' . $item->ID;
// Write if saved, versioned, and not already added
if ($item->isInDB() && !isset($list[$itemKey])) {
$list[$itemKey] = $item;
$added[$itemKey] = $item;
}
// Add joined record (from many_many through) automatically
$joined = $item->getJoin();
if ($joined) {
$this->mergeRelatedObject($list, $added, $joined);
}
}
}