mirror of
https://github.com/silverstripe/silverstripe-framework
synced 2024-10-22 14:05:37 +02:00
API: Allow for user-created objects to have values passed in the constructor (#8591)
This commit is contained in:
parent
eed2f59c37
commit
b810b7d5c9
@ -587,7 +587,7 @@ class Injector implements ContainerInterface
|
||||
if ((!$type || $type !== self::PROTOTYPE)
|
||||
&& empty($constructorParams)
|
||||
&& is_subclass_of($class, DataObject::class)) {
|
||||
$constructorParams = [null, true];
|
||||
$constructorParams = [null, DataObject::CREATE_SINGLETON];
|
||||
}
|
||||
|
||||
$factory = isset($spec['factory']) ? $this->get($spec['factory']) : $this->getObjectCreator();
|
||||
|
@ -293,7 +293,7 @@ class MySQLDatabase extends Database implements TransactionManager
|
||||
$objects = [];
|
||||
|
||||
foreach ($records as $record) {
|
||||
$objects[] = new $record['ClassName']($record);
|
||||
$objects[] = new $record['ClassName']($record, DataObject::CREATE_HYDRATED);
|
||||
}
|
||||
|
||||
$list = new PaginatedList(new ArrayList($objects));
|
||||
|
@ -819,6 +819,7 @@ class DataList extends ViewableData implements SS_List, Filterable, Sortable, Li
|
||||
|
||||
/**
|
||||
* Create a DataObject from the given SQL row
|
||||
* If called without $row['ID'] set, then a new object will be created rather than rehydrated.
|
||||
*
|
||||
* @param array $row
|
||||
* @return DataObject
|
||||
@ -841,7 +842,9 @@ class DataList extends ViewableData implements SS_List, Filterable, Sortable, Li
|
||||
$class = $row['RecordClassName'];
|
||||
}
|
||||
|
||||
$item = Injector::inst()->create($class, $row, false, $this->getQueryParams());
|
||||
$creationType = empty($row['ID']) ? DataObject::CREATE_OBJECT : DataObject::CREATE_HYDRATED;
|
||||
|
||||
$item = Injector::inst()->create($class, $row, $creationType, $this->getQueryParams());
|
||||
|
||||
return $item;
|
||||
}
|
||||
|
@ -183,6 +183,25 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity
|
||||
*/
|
||||
const CHANGE_VALUE = 2;
|
||||
|
||||
/**
|
||||
* Value for 2nd argument to constructor, indicating that a new record is being created
|
||||
* Setters will be called on fields passed, and defaults will be populated
|
||||
*/
|
||||
const CREATE_OBJECT = 0;
|
||||
|
||||
/**
|
||||
* Value for 2nd argument to constructor, indicating that a record is a singleton representing the whole type,
|
||||
* e.g. to call requireTable() in dev/build
|
||||
* Defaults will not be populated and data passed will be ignored
|
||||
*/
|
||||
const CREATE_SINGLETON = 1;
|
||||
|
||||
/**
|
||||
* Value for 2nd argument to constructor, indicating that a record is being hydrated from the database
|
||||
* Neither setters and nor default population will be called
|
||||
*/
|
||||
const CREATE_HYDRATED = 2;
|
||||
|
||||
/**
|
||||
* An array indexed by fieldname, true if the field has been changed.
|
||||
* Use {@link getChangedFields()} and {@link isChanged()} to inspect
|
||||
@ -318,88 +337,115 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity
|
||||
/**
|
||||
* 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 $record Initial record content, or rehydrated record content, depending on $creationType
|
||||
* @param int|boolean $creationType Set to DataObject::CREATE_OBJECT, DataObject::CREATE_HYDRATED, or DataObject::CREATE_SINGLETON. Used by SilverStripe internals as best left as the default by regular users.
|
||||
* @param array $queryParams List of DataQuery params necessary to lazy load, or load related objects.
|
||||
*/
|
||||
public function __construct($record = null, $isSingleton = false, $queryParams = [])
|
||||
public function __construct($record = [], $creationType = self::CREATE_OBJECT, $queryParams = [])
|
||||
{
|
||||
parent::__construct();
|
||||
|
||||
// Legacy $record default
|
||||
if ($record === null) {
|
||||
$record = [];
|
||||
}
|
||||
|
||||
// Legacy $isSingleton boolean
|
||||
if (!is_int($creationType)) {
|
||||
if (!is_bool($creationType)) {
|
||||
user_error('Creation type is neither boolean (old isSingleton arg) nor integer (new arg), please review your code', E_USER_WARNING);
|
||||
}
|
||||
$creationType = $creationType ? self::CREATE_SINGLETON : self::CREATE_OBJECT;
|
||||
}
|
||||
|
||||
// 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 = [
|
||||
'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 = [];
|
||||
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;
|
||||
|
||||
switch ($creationType) {
|
||||
// Hydrate a record from the database
|
||||
case self::CREATE_HYDRATED:
|
||||
if (!is_array($record) || empty($record['ID'])) {
|
||||
throw new \InvalidArgumentException("Hydrated records must be passed a record array including an ID");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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->record = $record;
|
||||
|
||||
// Identify fields that should be lazy loaded, but only on existing records
|
||||
// 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;
|
||||
$this->changed = [];
|
||||
$this->changeForced = false;
|
||||
|
||||
break;
|
||||
|
||||
// Create a new object, using the constructor argument as the initial content
|
||||
case self::CREATE_OBJECT:
|
||||
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 = [];
|
||||
}
|
||||
|
||||
// Default columns
|
||||
$this->record['ID'] = empty($record['ID']) ? 0 : $record['ID'];
|
||||
$this->record['ClassName'] = static::class;
|
||||
$this->record['RecordClassName'] = static::class;
|
||||
unset($record['ID']);
|
||||
$this->original = $this->record;
|
||||
|
||||
$this->populateDefaults();
|
||||
|
||||
// prevent populateDefaults() and setField() from marking overwritten defaults as changed
|
||||
$this->changed = [];
|
||||
$this->changeForced = false;
|
||||
|
||||
// Set the data passed in the constructor, allowing for defaults and calling setters
|
||||
// This will mark fields as changed
|
||||
if ($record) {
|
||||
$this->update($record);
|
||||
}
|
||||
break;
|
||||
|
||||
case self::CREATE_SINGLETON:
|
||||
// No setting happens for a singleton
|
||||
$this->record['ID'] = 0;
|
||||
$this->record['ClassName'] = static::class;
|
||||
$this->record['RecordClassName'] = static::class;
|
||||
$this->original = $this->record;
|
||||
$this->changed = [];
|
||||
$this->changeForced = false;
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new \LogicException('Bad creationType ' . $this->creationType);
|
||||
}
|
||||
|
||||
$this->original = $this->record;
|
||||
|
||||
// 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 = [];
|
||||
$this->changeForced = false;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -703,7 +749,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity
|
||||
$originalClass = $this->ClassName;
|
||||
|
||||
/** @var DataObject $newInstance */
|
||||
$newInstance = Injector::inst()->create($newClassName, $this->record, false);
|
||||
$newInstance = Injector::inst()->create($newClassName, $this->record, self::CREATE_HYDRATED);
|
||||
|
||||
// Modify ClassName
|
||||
if ($newClassName != $originalClass) {
|
||||
@ -920,13 +966,18 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity
|
||||
|
||||
/**
|
||||
* Convert this object to a map.
|
||||
* Note that it has the following quirks:
|
||||
* - custom getters, including those that adjust the result of database fields, won't be executed
|
||||
* - NULL values won't be returned.
|
||||
*
|
||||
* @return array The data as a map.
|
||||
*/
|
||||
public function toMap()
|
||||
{
|
||||
$this->loadLazyFields();
|
||||
return $this->record;
|
||||
return array_filter($this->record, function ($val) {
|
||||
return $val !== null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -57,12 +57,13 @@ class SSListExporterTest extends SapphireTest
|
||||
public function testToMapReturnsDataOfDataObjects()
|
||||
{
|
||||
$data = [
|
||||
'ID' => 5,
|
||||
'Foo' => 'Bar',
|
||||
'Baz' => 'Boom',
|
||||
'One' => 'Two'
|
||||
];
|
||||
|
||||
$map = $this->exporter->toMap(DataObject::create($data));
|
||||
$map = $this->exporter->toMap(DataObject::create($data, DataObject::CREATE_HYDRATED));
|
||||
|
||||
$this->assertEquals($data, $map, 'Map should match data passed to DataObject');
|
||||
}
|
||||
|
@ -206,8 +206,8 @@ class DataObjectTest extends SapphireTest
|
||||
$this->assertEquals('John', $player->FirstName);
|
||||
$this->assertEquals('Doe', $player->Surname);
|
||||
|
||||
// IDs should be stored as integers, not strings
|
||||
$player = new DataObjectTest\Player(['ID' => '5']);
|
||||
// Note that automatic conversion of IDs to integer no longer happens as the DB layer does that for us now
|
||||
$player = new DataObjectTest\Player(['ID' => 5]);
|
||||
$this->assertSame(5, $player->ID);
|
||||
}
|
||||
|
||||
@ -1306,6 +1306,7 @@ class DataObjectTest extends SapphireTest
|
||||
'HasOneRelationshipID',
|
||||
'ExtendedHasOneRelationshipID',
|
||||
'SubclassDatabaseField',
|
||||
'SubclassFieldWithOverride',
|
||||
'ParentTeamID',
|
||||
];
|
||||
$actual = array_keys($subteamSpecifications);
|
||||
@ -1321,6 +1322,7 @@ class DataObjectTest extends SapphireTest
|
||||
$expected = [
|
||||
'ID',
|
||||
'SubclassDatabaseField',
|
||||
'SubclassFieldWithOverride',
|
||||
'ParentTeamID',
|
||||
];
|
||||
$actual = array_keys($subteamFields);
|
||||
@ -2255,9 +2257,15 @@ class DataObjectTest extends SapphireTest
|
||||
|
||||
$map = $obj->toMap();
|
||||
|
||||
$this->assertArrayHasKey('ID', $map, 'Contains base fields');
|
||||
$this->assertArrayHasKey('Title', $map, 'Contains fields from parent class');
|
||||
$this->assertArrayHasKey('SubclassDatabaseField', $map, 'Contains fields from concrete class');
|
||||
$this->assertArrayHasKey('ID', $map, 'Should contain ID');
|
||||
$this->assertArrayHasKey('ClassName', $map, 'Should contain ClassName');
|
||||
$this->assertArrayHasKey('Created', $map, 'Should contain base Created');
|
||||
$this->assertArrayHasKey('LastEdited', $map, 'Should contain base LastEdited');
|
||||
$this->assertArrayHasKey('Title', $map, 'Should contain fields from parent class');
|
||||
$this->assertArrayHasKey('SubclassDatabaseField', $map, 'Should contain fields from concrete class');
|
||||
|
||||
$this->assertEquals('DB value of SubclassFieldWithOverride (override)', $obj->SubclassFieldWithOverride, 'Object uses custom field getter');
|
||||
$this->assertEquals('DB value of SubclassFieldWithOverride', $map['SubclassFieldWithOverride'], 'toMap does not use custom field getter');
|
||||
|
||||
$this->assertEquals(
|
||||
$obj->ID,
|
||||
@ -2275,8 +2283,17 @@ class DataObjectTest extends SapphireTest
|
||||
'Contains values from concrete class fields'
|
||||
);
|
||||
|
||||
$newObj = new DataObjectTest\SubTeam();
|
||||
$this->assertArrayHasKey('Title', $map, 'Contains null fields');
|
||||
$newObj = new DataObjectTest\SubTeam(['Title' => null]);
|
||||
$this->assertArrayNotHasKey('Title', $newObj->toMap(), 'Should not contain new null fields');
|
||||
|
||||
$newObj->Title = '';
|
||||
$this->assertArrayHasKey('Title', $newObj->toMap(), 'Should contain fields once they are set, even if falsey');
|
||||
|
||||
$newObj->Title = null;
|
||||
$this->assertArrayNotHasKey('Title', $newObj->toMap(), 'Should not contain reset-to-null fields');
|
||||
|
||||
$this->objFromFixture(DataObjectTest\SubTeam::class, 'subteam3_with_empty_fields');
|
||||
$this->assertArrayNotHasKey('SubclassDatabaseField', $newObj->toMap(), 'Should not contain null re-hydrated fields');
|
||||
}
|
||||
|
||||
public function testIsEmpty()
|
||||
@ -2520,4 +2537,39 @@ class DataObjectTest extends SapphireTest
|
||||
'Root is 2 step remove from grand children. It was not written on a shallow recursive write.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the different methods for creating DataObjects.
|
||||
* Note that using anything other than the default option should generally be left to ORM interanls.
|
||||
*/
|
||||
public function testDataObjectCreationTypes()
|
||||
{
|
||||
|
||||
// Test the default (DataObject::CREATE_OBJECT)
|
||||
// Defaults are used, changes of non-default fields are tracked
|
||||
$staff = new DataObjectTest\Staff([
|
||||
'Salary' => 50,
|
||||
]);
|
||||
$this->assertEquals('Staff', $staff->EmploymentType);
|
||||
$this->assertEquals(['Salary'], array_keys($staff->getChangedFields()));
|
||||
|
||||
|
||||
// Test hydration (DataObject::CREATE_HYDRATED)
|
||||
// Defaults are not used, changes are not tracked
|
||||
$staff = new DataObjectTest\Staff([
|
||||
'ID' => 5,
|
||||
'Salary' => 50,
|
||||
], DataObject::CREATE_HYDRATED);
|
||||
$this->assertEquals(null, $staff->EmploymentType);
|
||||
$this->assertEquals([], $staff->getChangedFields());
|
||||
|
||||
// Test singleton (DataObject::CREATE_SINGLETON)
|
||||
// Values are ingored
|
||||
$staff = new DataObjectTest\Staff([
|
||||
'Salary' => 50,
|
||||
], DataObject::CREATE_SINGLETON);
|
||||
$this->assertEquals(null, $staff->EmploymentType);
|
||||
$this->assertEquals(null, $staff->Salary);
|
||||
$this->assertEquals([], $staff->getChangedFields());
|
||||
}
|
||||
}
|
||||
|
@ -70,6 +70,7 @@ SilverStripe\ORM\Tests\DataObjectTest\SubTeam:
|
||||
subteam1:
|
||||
Title: Subteam 1
|
||||
SubclassDatabaseField: Subclassed 1
|
||||
SubclassFieldWithOverride: DB value of SubclassFieldWithOverride
|
||||
ExtendedDatabaseField: Extended 1
|
||||
ParentTeam: =>SilverStripe\ORM\Tests\DataObjectTest\Team.team1
|
||||
Sponsors:
|
||||
|
@ -9,7 +9,8 @@ class SubTeam extends Team implements TestOnly
|
||||
private static $table_name = 'DataObjectTest_SubTeam';
|
||||
|
||||
private static $db = [
|
||||
'SubclassDatabaseField' => 'Varchar'
|
||||
'SubclassDatabaseField' => 'Varchar',
|
||||
'SubclassFieldWithOverride' => 'Varchar',
|
||||
];
|
||||
|
||||
private static $has_one = [
|
||||
@ -25,4 +26,13 @@ class SubTeam extends Team implements TestOnly
|
||||
'Position' => 'Varchar(100)'
|
||||
]
|
||||
];
|
||||
|
||||
/**
|
||||
* Override the value of SubclassFieldWithOverride
|
||||
* @return string Suffixes " (override)" to SubclassFieldWithOverride value
|
||||
*/
|
||||
public function getSubclassFieldWithOverride()
|
||||
{
|
||||
return $this->getField('SubclassFieldWithOverride') . ' (override)';
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user