silverstripe-framework/dev/FixtureBlueprint.php
Damian Mooyman c6457c50e9
API Allow has_many fixtures to be declared with array format as well as many_many (#5944)
BUG Fix issue with parsing of extrafields in fixtures
BUG Fix issue in duplicate relation name, and ensure FixtureBlueprint fails on these
2016-09-12 14:01:08 +01:00

332 lines
9.7 KiB
PHP

<?php
/**
* A blueprint on how to create instances of a certain {@link DataObject} subclass.
*
* Relies on a {@link FixtureFactory} to manage database relationships between instances,
* and manage the mappings between fixture identifiers and their database IDs.
*
* @package framework
* @subpackage core
*/
class FixtureBlueprint {
/**
* @var array Map of field names to values. Supersedes {@link DataObject::$defaults}.
*/
protected $defaults = array();
/**
* @var String Arbitrary name by which this fixture type can be referenced.
*/
protected $name;
/**
* @var String Subclass of {@link DataObject}
*/
protected $class;
/**
* @var array
*/
protected $callbacks = array(
'beforeCreate' => array(),
'afterCreate' => array(),
);
/** @config */
private static $dependencies = array(
'factory' => '%$FixtureFactory'
);
/**
* @param String $name
* @param String $class Defaults to $name
* @param array $defaults
*/
public function __construct($name, $class = null, $defaults = array()) {
if(!$class) $class = $name;
if(!is_subclass_of($class, 'DataObject')) {
throw new InvalidArgumentException(sprintf(
'Class "%s" is not a valid subclass of DataObject',
$class
));
}
$this->name = $name;
$this->class = $class;
$this->defaults = $defaults;
}
/**
* @param String $identifier Unique identifier for this fixture type
* @param Array $data Map of property names to their values.
* @param Array $fixtures Map of fixture names to an associative array of their in-memory
* identifiers mapped to their database IDs. Used to look up
* existing fixtures which might be referenced in the $data attribute
* via the => notation.
* @return DataObject
*/
public function createObject($identifier, $data = null, $fixtures = null) {
// We have to disable validation while we import the fixtures, as the order in
// which they are imported doesnt guarantee valid relations until after the import is complete.
$validationenabled = Config::inst()->get('DataObject', 'validation_enabled');
Config::inst()->update('DataObject', 'validation_enabled', false);
$this->invokeCallbacks('beforeCreate', array($identifier, &$data, &$fixtures));
try {
$class = $this->class;
$obj = DataModel::inst()->$class->newObject();
// If an ID is explicitly passed, then we'll sort out the initial write straight away
// This is just in case field setters triggered by the population code in the next block
// Call $this->write(). (For example, in FileTest)
if(isset($data['ID'])) {
$obj->ID = $data['ID'];
// The database needs to allow inserting values into the foreign key column (ID in our case)
$conn = DB::get_conn();
if(method_exists($conn, 'allowPrimaryKeyEditing')) {
$conn->allowPrimaryKeyEditing(ClassInfo::baseDataClass($class), true);
}
$obj->write(false, true);
if(method_exists($conn, 'allowPrimaryKeyEditing')) {
$conn->allowPrimaryKeyEditing(ClassInfo::baseDataClass($class), false);
}
}
// Populate defaults
if($this->defaults) foreach($this->defaults as $fieldName => $fieldVal) {
if(isset($data[$fieldName]) && $data[$fieldName] !== false) continue;
if(is_callable($fieldVal)) {
$obj->$fieldName = $fieldVal($obj, $data, $fixtures);
} else {
$obj->$fieldName = $fieldVal;
}
}
// Populate overrides
if($data) foreach($data as $fieldName => $fieldVal) {
// Defer relationship processing
if(
$obj->manyManyComponent($fieldName)
|| $obj->hasManyComponent($fieldName)
|| $obj->hasOneComponent($fieldName)
) {
continue;
}
$this->setValue($obj, $fieldName, $fieldVal, $fixtures);
}
$obj->write();
// Save to fixture before relationship processing in case of reflexive relationships
if(!isset($fixtures[$class])) {
$fixtures[$class] = array();
}
$fixtures[$class][$identifier] = $obj->ID;
// Populate all relations
if($data) foreach($data as $fieldName => $fieldVal) {
$isManyMany = $obj->manyManyComponent($fieldName);
$isHasMany = $obj->hasManyComponent($fieldName);
if ($isManyMany && $isHasMany) {
throw new InvalidArgumentException("$fieldName is both many_many and has_many");
}
if($isManyMany || $isHasMany) {
$obj->write();
// Many many components need a little extra work to extract extrafields
if(is_array($fieldVal) && $isManyMany) {
// handle lists of many_many relations. Each item can
// specify the many_many_extraFields against each
// related item.
foreach($fieldVal as $relVal) {
// Check for many_many_extrafields
$extrafields = array();
if (is_array($relVal)) {
// Item is either first row, or key in yet another nested array
$item = key($relVal);
if (is_array($relVal[$item]) && count($relVal) === 1) {
// Extra fields from nested array
$extrafields = $relVal[$item];
} else {
// Extra fields from subsequent items
array_shift($relVal);
$extrafields = $relVal;
}
} else {
$item = $relVal;
}
$id = $this->parseValue($item, $fixtures);
$obj->getManyManyComponents($fieldName)->add(
$id, $extrafields
);
}
} else {
$items = is_array($fieldVal)
? $fieldVal
: preg_split('/ *, */',trim($fieldVal));
$parsedItems = array();
foreach($items as $item) {
// Check for correct format: =><relationname>.<identifier>.
// Ignore if the item has already been replaced with a numeric DB identifier
if(!is_numeric($item) && !preg_match('/^=>[^\.]+\.[^\.]+/', $item)) {
throw new InvalidArgumentException(sprintf(
'Invalid format for relation "%s" on class "%s" ("%s")',
$fieldName,
$class,
$item
));
}
$parsedItems[] = $this->parseValue($item, $fixtures);
}
if($isHasMany) {
$obj->getComponents($fieldName)->setByIDList($parsedItems);
} elseif($isManyMany) {
$obj->getManyManyComponents($fieldName)->setByIDList($parsedItems);
}
}
} else {
$hasOneField = preg_replace('/ID$/', '', $fieldName);
if($className = $obj->hasOneComponent($hasOneField)) {
$obj->{$hasOneField.'ID'} = $this->parseValue($fieldVal, $fixtures, $fieldClass);
// Inject class for polymorphic relation
if($className === 'DataObject') {
$obj->{$hasOneField.'Class'} = $fieldClass;
}
}
}
}
$obj->write();
// If LastEdited was set in the fixture, set it here
if($data && array_key_exists('LastEdited', $data)) {
$this->overrideField($obj, 'LastEdited', $data['LastEdited'], $fixtures);
}
// Ensure Folder objects exist physically, as otherwise future File fixtures can't detect them
if($obj instanceof Folder) {
Filesystem::makeFolder($obj->getFullPath());
}
} catch(Exception $e) {
Config::inst()->update('DataObject', 'validation_enabled', $validationenabled);
throw $e;
}
Config::inst()->update('DataObject', 'validation_enabled', $validationenabled);
$this->invokeCallbacks('afterCreate', array($obj, $identifier, &$data, &$fixtures));
return $obj;
}
/**
* @param Array $defaults
*/
public function setDefaults($defaults) {
$this->defaults = $defaults;
return $this;
}
/**
* @return Array
*/
public function getDefaults() {
return $this->defaults;
}
/**
* @return String
*/
public function getClass() {
return $this->class;
}
/**
* See class documentation.
*
* @param String $type
* @param callable $callback
*/
public function addCallback($type, $callback) {
if(!array_key_exists($type, $this->callbacks)) {
throw new InvalidArgumentException(sprintf('Invalid type "%s"', $type));
}
$this->callbacks[$type][] = $callback;
return $this;
}
/**
* @param String $type
* @param callable $callback
*/
public function removeCallback($type, $callback) {
$pos = array_search($callback, $this->callbacks[$type]);
if($pos !== false) unset($this->callbacks[$type][$pos]);
return $this;
}
protected function invokeCallbacks($type, $args = array()) {
foreach($this->callbacks[$type] as $callback) {
call_user_func_array($callback, $args);
}
}
/**
* Parse a value from a fixture file. If it starts with =>
* it will get an ID from the fixture dictionary
*
* @param string $fieldVal
* @param array $fixtures See {@link createObject()}
* @param string $class If the value parsed is a class relation, this parameter
* will be given the value of that class's name
* @return string Fixture database ID, or the original value
*/
protected function parseValue($value, $fixtures = null, &$class = null) {
if(substr($value,0,2) == '=>') {
// Parse a dictionary reference - used to set foreign keys
list($class, $identifier) = explode('.', substr($value,2), 2);
if($fixtures && !isset($fixtures[$class][$identifier])) {
throw new InvalidArgumentException(sprintf(
'No fixture definitions found for "%s"',
$value
));
}
return $fixtures[$class][$identifier];
} else {
// Regular field value setting
return $value;
}
}
protected function setValue($obj, $name, $value, $fixtures = null) {
$obj->$name = $this->parseValue($value, $fixtures);
}
protected function overrideField($obj, $fieldName, $value, $fixtures = null) {
$table = ClassInfo::table_for_object_field(get_class($obj), $fieldName);
$value = $this->parseValue($value, $fixtures);
DB::manipulate(array(
$table => array(
"command" => "update", "id" => $obj->ID,
"fields" => array($fieldName => $value)
)
));
$obj->$fieldName = $value;
}
}