Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

FIX Ensure getters and setters are respected #10708

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/ORM/FieldType/DBBoolean.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,11 @@ public function saveInto($dataObject)
{
$fieldName = $this->name;
if ($fieldName) {
$dataObject->setField($fieldName, $this->value ? 1 : 0);
if ($this->value instanceof DBField) {
$this->value->saveInto($dataObject);
} else {
$dataObject->__set($fieldName, $this->value ? 1 : 0);
}
} else {
$class = static::class;
throw new \RuntimeException("DBField::saveInto() Called on a nameless '$class' object");
Expand Down
8 changes: 6 additions & 2 deletions src/ORM/FieldType/DBComposite.php
Original file line number Diff line number Diff line change
Expand Up @@ -221,8 +221,12 @@ public function saveInto($dataObject)
{
foreach ($this->compositeDatabaseFields() as $field => $spec) {
// Save into record
$key = $this->getName() . $field;
$dataObject->setField($key, $this->getField($field));
if ($this->value instanceof DBField) {
$this->value->saveInto($dataObject);
} else {
$key = $this->getName() . $field;
$dataObject->__set($key, $this->getField($field));
}
}
}

Expand Down
8 changes: 6 additions & 2 deletions src/ORM/FieldType/DBDecimal.php
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,12 @@ public function saveInto($dataObject)
$fieldName = $this->name;

if ($fieldName) {
$value = (float) preg_replace('/[^0-9.\-\+]/', '', $this->value ?? '');
$dataObject->setField($fieldName, $value);
if ($this->value instanceof DBField) {
$this->value->saveInto($dataObject);
} else {
$value = (float) preg_replace('/[^0-9.\-\+]/', '', $this->value ?? '');
$dataObject->__set($fieldName, $value);
}
} else {
throw new \UnexpectedValueException(
"DBField::saveInto() Called on a nameless '" . static::class . "' object"
Expand Down
6 changes: 5 additions & 1 deletion src/ORM/FieldType/DBField.php
Original file line number Diff line number Diff line change
Expand Up @@ -542,7 +542,11 @@ public function saveInto($dataObject)
"DBField::saveInto() Called on a nameless '" . static::class . "' object"
);
}
$dataObject->setField($fieldName, $this->value);
if ($this->value instanceof self) {
$this->value->saveInto($dataObject);
} else {
$dataObject->__set($fieldName, $this->value);
}
Comment on lines +545 to +549
Copy link
Member

@emteknetnz emteknetnz Feb 27, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if ($this->value instanceof self) {
$this->value->saveInto($dataObject);
} else {
$dataObject->__set($fieldName, $this->value);
}
$dataObject->$fieldName = $this->value;

We don't need $this->value->saveInto($dataObject) as it will be called here if $val is a DBField.

We should be using the arrow notation that calls __set() rather than calling __set() directly because there's some infinite recursion protection only when using arrow notation which I think happens at the PHP level, and also because calling __set() directly just looks weird.

Also update all the other fields that were changed in this PR

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need $this->value->saveInto($dataObject) as it will be called here if $val is a DBField.

This shortcuts passed that, avoiding repeated calls through this chain when it's not needed. Imagine the (admittedly unlikely) scenario where a DBField instance has another DBField instance as its value, which has another DBField instance as its value, etc. What you're proposing will go through the saveInto() => __set() => setField() => saveInto() loop for every instance. The way I've got it, all of that is resolved in saveInto() so that only the raw value gets passed through that series.

The if ($val instanceof DBField) check is necessary for when we do $obj->MyField = new DBField() i.e. set the dbfield instance to the value as though the value is a property. Imagine again that this new DBField we're setting as the value has a series of nested DBField values - again, your suggestion results in it going through the loop a bunch of times. With the way I've got it, we just go __set() => setField() => saveInto() at which point we resolve down to the raw value and only go down to __set() one last time.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should be using the arrow notation that calls __set() rather than calling __set() directly

What do you mean "the arrow notation"? Do you mean just pretend we're setting a property? If we do that, the tests fail.

because there's some infinite recursion protection only when using arrow notation which I think happens at the PHP level

The only way we'll run into that scenario is if a DBField has itself in the value chain, which is extremely unlikely. But if you like I can write some logic to detect that in saveInto which wouldn't be difficult to do.

because calling __set() directly just looks weird

That's not a good reason to not do the thing that preserves the expected behaviour. This is the only way to get the tests I've written passing, from what I can tell.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you mean "the arrow notation"? Do you mean just pretend we're setting a property?

Yes, that will call __set(). We should be able safety assume the property doesn't exist on the DataObject since it's in the context of DBField->saveInto(), where $fieldName is DBField->name and Silverstripe ORM revolves around DataObject->FieldName magic.

which is extremely unlikely

I triggered it 2 different ways while investigating ways to simplify this PR. Chris's example code calls setField(), so does ViewableData::__set(), so it doesn't seem too hard, particularly when some related code gets modified in the future.

This shortcuts passed that, avoiding repeated calls through this chain when it's not needed

I'd rather take the small performance hit of repeated calls (we're talking microseconds) and ensure that we're calling all logic that should be called, rather than fragmenting the code.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that will call __set(). We should be able safety assume the property doesn't exist on the DataObject since it's in the context of DBField->saveInto(), where $fieldName is DBField->name and Silverstripe ORM revolves around DataObject->FieldName magic.

But it won't call __set() if we're already coming from __set() which is the case when setting values as properties, which is the most common scenario. As I mentioned before, it will cause the tests to fail. I'm happy to write some quick anti-recursion logic to avoid the unlikely recursive scenario, but we can't just treat fields as properties from the dbfield because it won't always call __set() and will result in failed tests.

I triggered it 2 different ways while investigating ways to simplify this PR. Chris's example code calls setField(), so does ViewableData::__set(), so it doesn't seem too hard, particularly when some related code gets modified in the future.

Please provide a clear set of instructions for triggering the problematic behaviour, so that I can write a test against it and update the code to pass that test.

I'd rather take the small performance hit of repeated calls (we're talking microseconds) and ensure that we're calling all logic that should be called, rather than fragmenting the code.

It's not just about performance. People creating their own custom setMyField() setter methods won't be expecting those to get hit multiple times each time a field is set. I've got it down to a minimum of two times, which is already one two many but I think it's unavoidable having that second call while preserving otherwise expected behaviour. We should avoid repeated calls as it may cause unexpected side-effects in custom code which doesn't expect to be called multiple times per field set. "all logic that should be called" is being called in this PR. We're getting the DBField instance which has the raw value and then calling saveInto() on that field, and then going through all of the __set() logic.

Copy link
Member Author

@GuySartorelli GuySartorelli Feb 27, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll gladly add additional recursion protection if you want me to, as I've stated. I'll also gladly add tests against the scenario you've triggered 2 different ways if you tell me what those 2 different ways are, and then update the code to make those tests pass.
I won't change this PR to call $obj->$field = $val because I categorically think that's incorrect - and have validated this by trying it and seeing tests fail when I try it.

Copy link
Member

@emteknetnz emteknetnz Feb 28, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The whole __set() / setField() / setFieldValue() / saveInto() thing is so convoluted :-/

I see your point about having to call __set(), because we need to bypass the recursion protection that the arrow comes with when we go $obj->MyField = new DBField()

The if ($val instanceof DBField) check is necessary for when we do $obj->MyField = new DBField() i.e. set the dbfield instance to the value as though the value is a property.

Is this actually a real-world thing? Having to support the $val instance of DBField in DataObject::setField() which then calls $val->saveInto($this) is basically the root cause of the complexity here.

I did a search on installer for the following regexs

  • ->[A-Za-z]+ = new [A-Za-z]+Field
  • ->[A-Za-z]+ = [A-Za-z]+Field::create\(
    In every instances it's just assignments to an arbitary class property, not an assignment to a property on a dataobject that would trigger ViewableData::__set()

Copy link
Member Author

@GuySartorelli GuySartorelli Feb 28, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this actually a real-world thing? Having to support the $val instance of DBField in DataObject::setField() is basically the root cause of the complexity here.

If we were still pre-beta I'd say lets just get rid of that and see what breaks.... but I think it's too late to remove that.

At best we could mark passing DBField in as property as a deprecated behaviour so it'll be easy to remove in 6, but I think it's risky to remove it now. It feels like the sort of thing that there's some magic functionality somewhere we're unaware of with bad test coverage that will break and we won't realise until someone complains.

That said, if you wanna get Max's approval to remove it (we'd need that since it'd be a breaking change post-beta) I'm not super opposed to the idea.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we need an extra set of eyes across this to sanity check it? Or do you think it's okay now that you've understood where I was coming from by calling __set()? Or.... ??? Not sure what the next course of action is here?

Copy link
Member

@emteknetnz emteknetnz Feb 28, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've had look into the feasibility of removing $val->saveInto($this) within DataObject::setField() isn't viable. The reason comes down to DBComposite fields where the following notation is supported, for instance for DBMoney

$dataObject->MyDBField->methodOnDBField()

$dataObject would have have private static $db = [ 'MyDBField' => MyDBComposite::class ]; (I think)

}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/ORM/FieldType/DBPercentage.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ public function saveInto($dataObject)

$fieldName = $this->name;
if ($fieldName && $dataObject->$fieldName > 1.0) {
$dataObject->setField($fieldName, 1.0);
$dataObject->__set($fieldName, 1.0);
}
}
}
73 changes: 73 additions & 0 deletions tests/php/ORM/DBFieldTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,17 @@
use SilverStripe\ORM\FieldType\DBVarchar;
use SilverStripe\ORM\FieldType\DBText;
use SilverStripe\Dev\SapphireTest;
use SilverStripe\ORM\FieldType\DBField;
use SilverStripe\ORM\FieldType\DBYear;

/**
* Tests for DBField objects.
*/
class DBFieldTest extends SapphireTest
{
protected static $extra_dataobjects = [
DBFieldTest\TestDataObject::class,
];

/**
* Test the nullValue() method on DBField.
Expand Down Expand Up @@ -322,4 +326,73 @@ public function testStringFieldsWithMultibyteData()
$this->assertEquals('<P>ÅÄÖ</P>', DBHTMLText::create_field('HTMLFragment', '<p>åäö</p>')->UpperCase());
$this->assertEquals('<p>åäö</p>', DBHTMLText::create_field('HTMLFragment', '<p>ÅÄÖ</p>')->LowerCase());
}

public function testSaveInto()
{
$obj = new DBFieldTest\TestDataObject();
/** @var DBField $field */
$field = $obj->dbObject('Title');
$field->setValue('New Value');
$field->saveInto($obj);

$this->assertEquals('New Value', $obj->getField('Title'));
$this->assertEquals(1, $field->saveIntoCalledCount);
$this->assertEquals(1, $obj->setFieldCalledCount);
}

public function testSaveIntoNoRecursion()
{
$obj = new DBFieldTest\TestDataObject();
/** @var DBField $field */
$field = $obj->dbObject('Title');
$value = new DBFieldTest\TestDbField('Title');
$value->setValue('New Value');
$field->setValue($value);
$field->saveInto($obj);

$this->assertEquals('New Value', $obj->getField('Title'));
$this->assertEquals(1, $field->saveIntoCalledCount);
$this->assertEquals(1, $obj->setFieldCalledCount);
}

public function testSaveIntoAsProperty()
{
$obj = new DBFieldTest\TestDataObject();
/** @var DBField $field */
$field = $obj->dbObject('Title');
$field->setValue('New Value');
$obj->Title = $field;

$this->assertEquals('New Value', $obj->getField('Title'));
$this->assertEquals(1, $field->saveIntoCalledCount);
// Called twice because $obj->setField($field) => $field->saveInto() => $obj->setField('New Value')
$this->assertEquals(2, $obj->setFieldCalledCount);
}

public function testSaveIntoNoRecursionAsProperty()
{
$obj = new DBFieldTest\TestDataObject();
/** @var DBField $field */
$field = $obj->dbObject('Title');
$value = new DBFieldTest\TestDbField('Title');
$value->setValue('New Value');
$field->setValue($value);
$obj->Title = $field;

$this->assertEquals('New Value', $obj->getField('Title'));
$this->assertEquals(1, $field->saveIntoCalledCount);
// Called twice because $obj->setField($field) => $field->saveInto() => $obj->setField('New Value')
$this->assertEquals(2, $obj->setFieldCalledCount);
}

public function testSaveIntoRespectsSetters()
{
$obj = new DBFieldTest\TestDataObject();
/** @var DBField $field */
$field = $obj->dbObject('MyTestField');
$field->setValue('New Value');
$obj->MyTestField = $field;

$this->assertEquals('new value', $obj->getField('MyTestField'));
}
}
29 changes: 29 additions & 0 deletions tests/php/ORM/DBFieldTest/TestDataObject.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

namespace SilverStripe\ORM\Tests\DBFieldTest;

use SilverStripe\Dev\TestOnly;
use SilverStripe\ORM\DataObject;

class TestDataObject extends DataObject implements TestOnly
{
private static $table_name = 'DBFieldTest_TestDataObject';

private static $db = [
'Title' => TestDbField::class,
'MyTestField' => TestDbField::class,
];

public $setFieldCalledCount = 0;

public function setField($fieldName, $val)
{
$this->setFieldCalledCount++;
return parent::setField($fieldName, $val);
}

public function setMyTestField($val)
{
return $this->setField('MyTestField', strtolower($val));
}
}
42 changes: 42 additions & 0 deletions tests/php/ORM/DBFieldTest/TestDbField.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

namespace SilverStripe\ORM\Tests\DBFieldTest;

use SilverStripe\Core\Config\Config;
use SilverStripe\Dev\TestOnly;
use SilverStripe\ORM\DB;
use SilverStripe\ORM\FieldType\DBField;

class TestDbField extends DBField implements TestOnly
{
public function requireField()
{
// Basically the same as DBVarchar but we don't want to test with DBVarchar in case something
// changes in that class eventually.
$charset = Config::inst()->get(MySQLDatabase::class, 'charset');
$collation = Config::inst()->get(MySQLDatabase::class, 'collation');

$parts = [
'datatype' => 'varchar',
'precision' => 255,
'character set' => $charset,
'collate' => $collation,
'arrayValue' => $this->arrayValue
];

$values = [
'type' => 'varchar',
'parts' => $parts
];

DB::require_field($this->tableName, $this->name, $values);
}

public $saveIntoCalledCount = 0;

public function saveInto($dataObject)
{
$this->saveIntoCalledCount++;
return parent::saveInto($dataObject);
}
}
11 changes: 11 additions & 0 deletions tests/php/ORM/DataObjectTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ class DataObjectTest extends SapphireTest
DataObjectTest\TreeNode::class,
DataObjectTest\OverriddenDataObject::class,
DataObjectTest\InjectedDataObject::class,
DataObjectTest\SettersAndGetters::class,
];

protected function setUp(): void
Expand Down Expand Up @@ -2667,4 +2668,14 @@ public function testDBObjectEnum()
$vals = ['25.25', '50.00', '75.00', '100.50'];
$this->assertSame(array_combine($vals ?? [], $vals ?? []), $obj->dbObject('MyEnumWithDots')->enumValues());
}

public function testSettersAndGettersAreRespected()
{
$obj = new DataObjectTest\SettersAndGetters();
$obj->MyTestField = 'Some Value';
// Setter overrides it with all lower case
$this->assertSame('some value', $obj->getField('MyTestField'));
// Getter overrides it with all upper case
$this->assertSame('SOME VALUE', $obj->MyTestField);
}
}
25 changes: 25 additions & 0 deletions tests/php/ORM/DataObjectTest/SettersAndGetters.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

namespace SilverStripe\ORM\Tests\DataObjectTest;

use SilverStripe\Dev\TestOnly;
use SilverStripe\ORM\DataObject;

class SettersAndGetters extends DataObject implements TestOnly
{
private static $table_name = 'DataObjectTest_SettersAndGetters';

private static $db = [
'MyTestField' => 'Varchar(255)',
];

public function setMyTestField($val)
{
$this->setField('MyTestField', strtolower($val));
}

public function getMyTestField()
{
return strtoupper($this->getField('MyTestField'));
}
}