dibi icon indicating copy to clipboard operation
dibi copied to clipboard

Suboptimal behaviour when using `Result::fetchAssoc` in conjunction with `Result::setRowFactory`

Open dakujem opened this issue 5 years ago • 12 comments

Version: current 4.1

Bug Description

When using Result::fetchAssoc in conjunction with Result::setRowFactory, fetchAssoc may throw exception Unknown column 'foo' in associative descriptor. even though the property is accessible when accessing $row->foo via magic getters.

Steps To Reproduce

        $result->setRowFactory(function($data){
            return new class($data) {
                use Strict;
                private $data;
                public function __construct($data)
                {
                    $this->data = $data;
                }
                public function __get(string $name)
                {
                    return $this->data[$name] ?? $this->data->{$name} ?? null;
                }
            };
        });
        array_map(function($v){return $v->foo;},$result->fetchAll()); // works just fine
        $result->fetchAssoc('foo'); // will throw

Unfortunatelly, this is quite a common pattern with many ORMs. 😥

The culprit

the problem is in Result class, where using property_exists with magic props will fail, quoted:

The property_exists() function cannot detect properties that are magically accessible using the __get magic method.

Possible Solution

I'm not yet sure what a backward compatible solution should look like.

dakujem avatar Feb 19 '20 16:02 dakujem

This issue most probably plagues the Result::fetchPairs method as well, since property_exists is used there too.

dakujem avatar Feb 28 '20 07:02 dakujem

I was wondering if I could come up with my own solution to the problem, however, as many times before, I hit the wall called "private" or "final".

First, I could post a PR where one could set the result class created by the Connection class instead of hard-wired Dibi\Result. Simple. Then one could provide his own result class to overcome this issue.

However, since both fetchPairs and fetchAssoc are final, it would either involve lots of code duplication or lots of code rewrite in the Dibi library itself.

I would like to make Dibi support entities that use magic getters, which is quite common use case. What is your snace on this matter, David? @dg

dakujem avatar Feb 28 '20 08:02 dakujem

I think that fetchAssoc is not suitable for entities. Better way is to create new fetchEntities() function. (It does not necessarily have to be a method of the Result class descendant).

dg avatar Feb 28 '20 13:02 dg

Consider this case. Really simple and useful.

I want to fetch users and then have them automatically sorted into groups by company and account type. Somewhere in the database layer there is this code to fetch users:

$allUsers = $connection->query('SLEECT * FROM `users` WHERE ... ')
    ->setRowFactory(function($row){ return new UserEntity($row); })
    ->fetchAssoc('company|accountType|id');

Then somewhere else in the application where I only work with entities, there might be code to access users or iterate over them $allUsers[$company][$type][$userId].

To support this, I have to make a 3-levels nested foreach cycle to convert the result to entities, and what is worse, the level of nesting changes depending on how the argument to fetchAssoc looks... 👎

$result = [];
foreach($allUsers as $companyId => $group){
    foreach($group as $type => $users){
        foreach($usersas $userId => $row){
            $result[$companyId][$type][$userId] = new UserEntity($row);
        }
    }
}
return $result;

Why would not this be a supported case for fetchAssoc?

dakujem avatar Feb 28 '20 14:02 dakujem

I forgot to point out that the the suggested fetchEntities function would have to know the depth of nesting of the result, which it does not know.

And I consider parsing the argument to fetchAssoc a hack 🙂

dakujem avatar Feb 28 '20 14:02 dakujem

The solution would be to modify fetchAssoc to

  • not use $this->fetch() directly, but fetch row data via $this->getResultDriver()->fetch(true);
  • do all tranformations
  • apply $this->rowFactory & rowClass to the leaf, ie. the last item in structure

dg avatar Feb 28 '20 14:02 dg

WIP 6b342624d71a471f4d4514d3e6683b681913addc

dg avatar Feb 28 '20 15:02 dg

So bascally you modify the order to do the transformations first, then apply the row factory. Well at a point in time I found it strange that the row factory was invoked before fetchAssoc transformed the result, but it slipped of my mind. If this is not a BCB, then it seems more elegant to me. Since Result is tightly coupled to Connection, it should not be a BCB, at least not the the public interface. Good job.

dakujem avatar Feb 28 '20 15:02 dakujem

In fact, it is BC break :-/ See #284

In my opinion, this cannot be solved without BC. And I don't really want to deal with it :-)

dg avatar Feb 29 '20 11:02 dg

I wonder ... it's quite common to have an entity class put all the "row data" into one attribute and use magic accessors.

So you had the very same idea of soluton here #284, if i understand it correctly, which trned out to be a BCB.


Would making the result class configurable in Connection and removing the final modifiers from those functions in Result class be a BC break? that would allow us to modify the behaviour on the app side.

Furthermore, if there was an interface introduced that would be used along with the property_exists call, then that could solve the issue without a BC break, at the expense of adding a new interface to the library.

Something like the following.

// check columns
foreach ($assoc as $as) {
    // offsetExists ignores null in PHP 5.2.1, isset() surprisingly null accepts
    if (
        $as !== '[]' && $as !== '=' && $as !== '->' && $as !== '|' && !property_exists($row, $as) &&
        (!$row instanceof ExposesProperties || !$row->hasProperty($as))
    ) {
        throw new \InvalidArgumentException("Unknown column '$as' in associative descriptor.");
    }
}
interface ExposesProperties {
    public function hasProperty(string $name): bool;
}

The interface could then be implemented by the entity classes.

dakujem avatar Mar 02 '20 08:03 dakujem

Yes, I'm afraid any change can be a BC break. New interface seems like a ways how to solve. I have to think about it more and unfortunately I don't have time for it now.

dg avatar Mar 03 '20 16:03 dg

Related probem is with ->setRowClass(null). In that case, fetchAssoc() throws TypeError: property_exists(): Argument #1 ($object_or_class) must be of type object|string, array given

milo avatar Jun 23 '21 10:06 milo