phalcon / cphalcon

High performance, full-stack PHP framework delivered as a C extension.
https://phalcon.io
BSD 3-Clause "New" or "Revised" License
10.79k stars 1.96k forks source link

[BUG?] Unable to set a "belongsTo" field to null after loading the related model once #13133

Closed idevelop4you closed 6 years ago

idevelop4you commented 7 years ago

Expected and Actual Behavior

Having this schema and sample data for a user with related user image (not mandatory):

CREATE TABLE `image` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `filename` varchar(45) DEFAULT NULL,
  PRIMARY KEY (`id`)
);

CREATE TABLE `user` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `image_id` int(11) DEFAULT NULL,
  `name` varchar(45) NOT NULL,
  PRIMARY KEY (`id`),
  KEY `fk_user_image_idx` (`image_id`),
  CONSTRAINT `fk_user_image` FOREIGN KEY (`image_id`) REFERENCES `image` (`id`) ON DELETE NO ACTION ON UPDATE NO ACTION
);

INSERT INTO `image` (`filename`) VALUES ('test-image.jpg');
INSERT INTO `user` (`image_id`, `name`) VALUES ('1', 'test user');

And this models:

class Image extends Model {

    protected $id;
    protected $filename;

    public function getId() {
        return $this->id;
    }

    public function getFilename() {
        return $this->filename;
    }

    public function setFilename($filename) {
        $this->filename = $filename;
        return $this;
    }

    public function getSource() {
        return 'image';
    }

}
class User extends Model {

    protected $id;
    protected $image_id;
    protected $name;

    public function getId() {
        return $this->id;
    }

    public function getName() {
        return $this->name;
    }

    public function setName($name) {
        $this->name = $name;
        return $this;
    }

    public function getSource() {
        return 'user';
    }

    public function initialize(){
        $this->belongsTo('image_id', Image::class, 'id', array(
            'alias' => 'Image',
            'foreignKey' => array(
                'allowNulls' => true
            )
        ));
    }

    public function getImage(){
        return $this->Image;
    }

    public function setImage($image){
        $this->Image = $image;
        return $this;
    }
}

I want to remove a user image

Trying setting the Image property to null:

        $user = User::findFirst(1);
        $user->setImage(null);
        if(!$user->save())
            error_log(print_r($user->getMessages(),1));

It does not set the user image_id field to null and is giving no errors.

I then modified the setter so that if the specified model is null, it will also set the foreign key field to null:

    public function setImage($image){
        $this->Image = $image;
        if($image === null)
            $this->image_id = null;
        return $this;
    }

This works correctly with the code sample above but if the user related image was accesses at least once, it stop working:

        $user = User::findFirst(1);

        // Access user related image
        $image = $user->getImage();

        $user->setImage(null);
        if(!$user->save())
            error_log(print_r($user->getMessages(),1));

No errors but the image_id still contains the image id value and is not set to null.

Looking at the Model source code it seems that, when accessing the user image related record, it is cached in the *_related array and it is never removed so that when saving the record it save also the cached relation.

The Model setter never the clear _related array:

    public function __set(string property, value)
    {
        var lowerProperty, related, modelName, manager, lowerKey,
            relation, referencedModel, key, item, dirtyState;

        /**
         * Values are probably relationships if they are objects
         */
        if typeof value == "object" {
            if value instanceof ModelInterface {
                let dirtyState = this->_dirtyState;
                if (value->getDirtyState() != dirtyState) {
                    let dirtyState = self::DIRTY_STATE_TRANSIENT;
                }
                let lowerProperty = strtolower(property),
                    this->{lowerProperty} = value,
                    this->_related[lowerProperty] = value,
                    this->_dirtyState = dirtyState;
                return value;
            }
        }

                ....

As a workaround I overridden the __set method in my base model in the following way:

    public function __set($property, $value) {
        $lowerCaseProperty = strtolower($property);
        if($value === null){
            // Get current model class name
            $className = get_class($this);
            // Get the relation for the specified property (if any)
            $relation = $this->getModelsManager()->getRelationByAlias($className, $lowerCaseProperty);
            // If the relation exists, clear the related object
            if(is_object($relation) && $relation->getType() == Relation::BELONGS_TO){
                // Get the relation field on current model
                $relationField = $relation->getFields();
                // Set the relation field to null
                $this->$relationField = null;
                // Remove the related object from object cache (if present)
                if(is_array($this->_related) && array_key_exists($lowerCaseProperty, $this->_related))
                    unset($this->_related[$lowerCaseProperty]);
            }
        }
        return parent::__set($property, $value);
    }

Now I can simply call the setter with a null value and it works correctly:

    public function setImage($image){
        $this->Image = $image;
        return $this;
    }

Details

stale[bot] commented 6 years ago

Thank you for contributing to this issue. As it has been 90 days since the last activity, we are automatically closing the issue. This is often because the request was already solved in some way and it just wasn't updated or it's no longer applicable. If that's not the case, please feel free to either reopen this issue or open a new one. We will be more than happy to look at it again! You can read more here: https://blog.phalconphp.com/post/github-closing-old-issues

GammaGames commented 4 years ago

This is still a problem, @sergeyklay. As a workaround I created an unloadRelationship function in my model:


public function unloadRelationship($alias) {
    unset($this->$alias, $this->_related[$alias]);
}

It can be called easily:

$model->unloadRelationship('alias');

I found the solution here, but it feels really hacky: https://forum.phalcon.io/discussion/15407/problem-updating-model-property-when-accesing-to-related-model-v

dz3n commented 1 year ago

@niden

This issue still persists, I'm not able to set NULL to a prop which related to another model

        $this->hasOne('business_host_id', BusinessHost::class, 'id',
            [
                'alias' => 'BusinessHost',
            ]
        );

......
        $business = Business::findFirstById(1);
        $businessHost = $business->getBusinessHost();
        $business->setBusinessHostId(NULL);
        $business->update(); 

This code triggers a create on the related model, which is an unexpected behavior.