gabordemooij / redbean

ORM layer that creates models, config and database on the fly
https://www.redbeanphp.com
2.3k stars 280 forks source link

Model prefix per database #877

Closed benmajor closed 2 years ago

benmajor commented 2 years ago

I'm not sure whether this is actually possible given RedBean's use of constants for this, but I thought it would be worth asking anyway. This may be an edge case, but it's something I've run into on a couple of projects using RedBean now.

Sometimes, it would super-handy to define the model prefix depending upon the database that is currently defined.

Here's an example:

We have two databases, one named store and one named support. The former holds product catalogue data and so forth, and the latter exists as a CRM database. We can implement both databases into our project quite easily by giving each DB a unique name, and using R::selectDatabase( 'store') and R::selectDatabase('support') respectively. Using the traditional method, we would have defined the model prefix as follows:

define('REDBEAN_MODEL_PREFIX', '\\App\\Entity\\');

And this works, until we have Beans that exist in both the store and support databases, such as store.user (relates to store users; such as customers) and support.user (which relates to admin staff, customers and support agents). If we have defined an autoloaded model at App/Entity/User.php, beans from both databases will use the same model. Sometimes though, we need to define custom methods depending upon the database the bean has been located from, so my suggestion would be to update the method signature for addDatabase() to something like:

R::addDatabase( 
    'support', 
    'mysql://tmp/d1.db', 
    '{USER}', 
    '{PASSWORD}', 
    $frozen,
    '\\App\\Entity\\Support\\'
);

By passing in the model prefix as the last parameter, we could then have User.php exist in the following locations:

App/Entity/Store/User.php App/Entity/Support/User.php

I had initially thought that we may be able to implement this functionality by adding an event listener that responds to selectDatabase() method, but since we have generally defined the model prefix as a constant, this approach won't work. I'm also open to other suggestions to achieve this goal, though!

benmajor commented 2 years ago

Looking at this in a little more detail, it appears as though the constant is only used inside of the Bean Helper, so we could in theory port it out into the Facade, and handle it that way. I'll put together a PR to demonstrate.

Lynesth commented 2 years ago

Here's a working example of how you could achieve that my making your own BeanHelper if you're interested:

<?php

namespace {
        include 'rb-sqlite.php';

        class MyBeanHelper extends \RedBeanPHP\BeanHelper\SimpleFacadeBeanHelper {

                private $namespace = '';

                public function __construct($namespace) {
                        $this->namespace = $namespace;
                }

                public function getModelForBean(\RedBeanPHP\OODBBean $bean) {
                        $type = $bean->getMeta('type');
                        if ($this->namespace) {
                                $modelName = $this->namespace . '\\' . $type;
                                $obj = self::factory($modelName);
                                $obj->loadBean($bean);
                                return $obj;
                        } else {
                                return parent::getModelForBean($bean);
                        }
                }
        }
}

namespace Store {
        class User extends \RedBeanPHP\SimpleModel {
                public function whoami() {
                        echo "I'm a store user." . PHP_EOL;
                }
        }
}

namespace Support {
        class User extends \RedBeanPHP\SimpleModel {
                public function whoami() {
                        echo "I'm a support user." . PHP_EOL;
                }
        }
}

namespace {
        R::setup( 'sqlite:/tmp/sqlitetest.db' );
        R::freeze( FALSE );
        R::nuke();
        R::usePartialBeans( TRUE );
        R::getRedBean()->setBeanHelper( new MyBeanHelper('\\Store') );

        $storeUser = R::dispense('user');
        R::store($storeUser);
        $storeUser->whoami(); // "I'm a store user."

        R::getRedBean()->setBeanHelper( new MyBeanHelper('\\Support') );

        $supportUser = R::dispense('user');
        R::store($supportUser);
        $supportUser->whoami(); // "I'm a support user."
}
benmajor commented 2 years ago

Thanks @Lynesth, I've just put together a PR that makes this possible, and should maintain backwards compatible with the use of the REDBEAN_MODEL_PREFIX constant too. Please see https://github.com/gabordemooij/redbean/pull/878/

gabordemooij commented 2 years ago

This PR makes sense for those relying on namespaces and working with different databases. Not a very big crowd, but still useful I think for complex situations/advanced usage.

gabordemooij commented 2 years ago

Here's a working example of how you could achieve that my making your own BeanHelper if you're interested:

<?php

namespace {
        include 'rb-sqlite.php';

        class MyBeanHelper extends \RedBeanPHP\BeanHelper\SimpleFacadeBeanHelper {

                private $namespace = '';

                public function __construct($namespace) {
                        $this->namespace = $namespace;
                }

                public function getModelForBean(\RedBeanPHP\OODBBean $bean) {
                        $type = $bean->getMeta('type');
                        if ($this->namespace) {
                                $modelName = $this->namespace . '\\' . $type;
                                $obj = self::factory($modelName);
                                $obj->loadBean($bean);
                                return $obj;
                        } else {
                                return parent::getModelForBean($bean);
                        }
                }
        }
}

namespace Store {
        class User extends \RedBeanPHP\SimpleModel {
                public function whoami() {
                        echo "I'm a store user." . PHP_EOL;
                }
        }
}

namespace Support {
        class User extends \RedBeanPHP\SimpleModel {
                public function whoami() {
                        echo "I'm a support user." . PHP_EOL;
                }
        }
}

namespace {
        R::setup( 'sqlite:/tmp/sqlitetest.db' );
        R::freeze( FALSE );
        R::nuke();
        R::usePartialBeans( TRUE );
        R::getRedBean()->setBeanHelper( new MyBeanHelper('\\Store') );

        $storeUser = R::dispense('user');
        R::store($storeUser);
        $storeUser->whoami(); // "I'm a store user."

        R::getRedBean()->setBeanHelper( new MyBeanHelper('\\Support') );

        $supportUser = R::dispense('user');
        R::store($supportUser);
        $supportUser->whoami(); // "I'm a support user."
}

This is a really beautiful solution by the way.

gabordemooij commented 2 years ago

@Lynesth @benmajor I think the PR makes sense, but I'd rather not change the Simple BeanHelper, can we not just merge the two solutions? I mean I understand that @benmajor wants to use the namespace through a parameter, that's fine, but @Lynesth 's solution is probably the cleanest OO-way to do it.

benmajor commented 2 years ago

Thanks @gabordemooij. Indeed, the solution proposed by @Lynesth is exactly what I have implemented while awaiting for the review of this PR (with some slight amendments to work with our custom kernel), and I have to say that it is very elegant.

We effectively added the R::selectDatabase() call into our kernel's selectDatabase() method, and inside of that assign the Bean Helper based on the assigned prefix. It is a very clean solution to the problem, and I agree that the scenario we are discussing here is probably a very specific edge-case, but I just feel that the PR helps to maintain the 'automagical' nature of RedBean.

rivets commented 2 years ago

I'm using Lynesth's helper in order to be able to separate models into different directories, and it's great. However, I did find that I had to add a file existence test to the code when the system tried to look for a model in my extra directory for a bean that did not have one defined otherwise it gave a missing file error.

gabordemooij commented 2 years ago

Slightly different solution:

https://github.com/gabordemooij/redbean/pull/887

Should work like this:

R::addDatabase( 
    'support', 
    'mysql://tmp/d1.db', 
    '{USER}', 
    '{PASSWORD}', 
    $frozen,
   DBPrefix( '\\App\\Entity\\Support\\')
);

Where the convience function DBPrefix() is short for:

new DynamicBeanHelper( '\\App\\Entity\\Support\\');