Proper Repository Pattern Design in PHP?

后端 未结 11 773
天涯浪人
天涯浪人 2020-11-29 14:22

Preface: I\'m attempting to use the repository pattern in an MVC architecture with relational databases.

I\'ve recently started learning TDD in PHP, and I\'

相关标签:
11条回答
  • 2020-11-29 14:37

    These are some different solutions I've seen. There are pros and cons to each of them, but it is for you to decide.

    Issue #1: Too many fields

    This is an important aspect especially when you take in to account Index-Only Scans. I see two solutions to dealing with this problem. You can update your functions to take in an optional array parameter that would contain a list of a columns to return. If this parameter is empty you'd return all of the columns in the query. This can be a little weird; based off the parameter you could retrieve an object or an array. You could also duplicate all of your functions so that you have two distinct functions that run the same query, but one returns an array of columns and the other returns an object.

    public function findColumnsById($id, array $columns = array()){
        if (empty($columns)) {
            // use *
        }
    }
    
    public function findById($id) {
        $data = $this->findColumnsById($id);
    }
    

    Issue #2: Too many methods

    I briefly worked with Propel ORM a year ago and this is based off what I can remember from that experience. Propel has the option to generate its class structure based off the existing database schema. It creates two objects for each table. The first object is a long list of access function similar to what you have currently listed; findByAttribute($attribute_value). The next object inherits from this first object. You can update this child object to build in your more complex getter functions.

    Another solution would be using __call() to map non defined functions to something actionable. Your __call method would be would be able to parse the findById and findByName into different queries.

    public function __call($function, $arguments) {
        if (strpos($function, 'findBy') === 0) {
            $parameter = substr($function, 6, strlen($function));
            // SELECT * FROM $this->table_name WHERE $parameter = $arguments[0]
        }
    }
    

    I hope this helps at least some what.

    0 讨论(0)
  • 2020-11-29 14:38

    I agree with @ryan1234 that you should pass around complete objects within the code and should use generic query methods to get those objects.

    Model::where(['attr1' => 'val1'])->get();
    

    For external/endpoint usage I really like the GraphQL method.

    POST /api/graphql
    {
        query: {
            Model(attr1: 'val1') {
                attr2
                attr3
            }
        }
    }
    
    0 讨论(0)
  • 2020-11-29 14:39

    I suggest https://packagist.org/packages/prettus/l5-repository as vendor to implement Repositories/Criterias etc ... in Laravel5 :D

    0 讨论(0)
  • 2020-11-29 14:47

    Based on my experience, here are some answers to your questions:

    Q: How do we deal with bringing back fields we don't need?

    A: From my experience this really boils down to dealing with complete entities versus ad-hoc queries.

    A complete entity is something like a User object. It has properties and methods, etc. It's a first class citizen in your codebase.

    An ad-hoc query returns some data, but we don't know anything beyond that. As the data gets passed around the application, it is done so without context. Is it a User? A User with some Order information attached? We don't really know.

    I prefer working with full entities.

    You are right that you will often bring back data you won't use, but you can address this in various ways:

    1. Aggressively cache the entities so you only pay the read price once from the database.
    2. Spend more time modeling your entities so they have good distinctions between them. (Consider splitting a large entity into two smaller entities, etc.)
    3. Consider having multiple versions of entities. You can have a User for the back end and maybe a UserSmall for AJAX calls. One might have 10 properties and one has 3 properties.

    The downsides of working with ad-hoc queries:

    1. You end up with essentially the same data across many queries. For example, with a User, you'll end up writing essentially the same select * for many calls. One call will get 8 of 10 fields, one will get 5 of 10, one will get 7 of 10. Why not replace all with one call that gets 10 out of 10? The reason this is bad is that it is murder to re-factor/test/mock.
    2. It becomes very hard to reason at a high level about your code over time. Instead of statements like "Why is the User so slow?" you end up tracking down one-off queries and so bug fixes tend to be small and localized.
    3. It's really hard to replace the underlying technology. If you store everything in MySQL now and want to move to MongoDB, it's a lot harder to replace 100 ad-hoc calls than it is a handful of entities.

    Q: I will have too many methods in my repository.

    A: I haven't really seen any way around this other than consolidating calls. The method calls in your repository really map to features in your application. The more features, the more data specific calls. You can push back on features and try to merge similar calls into one.

    The complexity at the end of the day has to exist somewhere. With a repository pattern we've pushed it into the repository interface instead of maybe making a bunch of stored procedures.

    Sometimes I have to tell myself, "Well it had to give somewhere! There are no silver bullets."

    0 讨论(0)
  • 2020-11-29 14:51

    I think graphQL is a good candidate in such a case to provide a large scale query language without increasing the complexity of data repositories.

    However, there's another solution if you don't want to go for the graphQL for now. By using a DTO where an object is used for carring the data between processes, in this case between the service/controller and the repository.

    An elegant answer is already provided above, however I'll try to give another example that I think it's simpler and could serve as a starting point for a new project.

    As shown in the code, we would need only 4 methods for CRUD operations. the find method would be used for listing and reading by passing object argument. Backend services could build the defined query object based on a URL query string or based on specific parameters.

    The query object (SomeQueryDto) could also implement specific interface if needed. and is easy to be extended later without adding complexity.

    <?php
    
    interface SomeRepositoryInterface
    {
        public function create(SomeEnitityInterface $entityData): SomeEnitityInterface;
        public function update(SomeEnitityInterface $entityData): SomeEnitityInterface;
        public function delete(int $id): void;
    
        public function find(SomeEnitityQueryInterface $query): array;
    }
    
    class SomeRepository implements SomeRepositoryInterface
    {
        public function find(SomeQueryDto $query): array
        {
            $qb = $this->getQueryBuilder();
    
            foreach ($query->getSearchParameters() as $attribute) {
                $qb->where($attribute['field'], $attribute['operator'], $attribute['value']);
            }
    
            return $qb->get();
        }
    }
    
    /**
     * Provide query data to search for tickets.
     *
     * @method SomeQueryDto userId(int $id, string $operator = null)
     * @method SomeQueryDto categoryId(int $id, string $operator = null)
     * @method SomeQueryDto completedAt(string $date, string $operator = null)
     */
    class SomeQueryDto
    {
        /** @var array  */
        const QUERYABLE_FIELDS = [
            'id',
            'subject',
            'user_id',
            'category_id',
            'created_at',
        ];
    
        /** @var array  */
        const STRING_DB_OPERATORS = [
            'eq' => '=', // Equal to
            'gt' => '>', // Greater than
            'lt' => '<', // Less than
            'gte' => '>=', // Greater than or equal to
            'lte' => '<=', // Less than or equal to
            'ne' => '<>', // Not equal to
            'like' => 'like', // Search similar text
            'in' => 'in', // one of range of values
        ];
    
        /**
         * @var array
         */
        private $searchParameters = [];
    
        const DEFAULT_OPERATOR = 'eq';
    
        /**
         * Build this query object out of query string.
         * ex: id=gt:10&id=lte:20&category_id=in:1,2,3
         */
        public static function buildFromString(string $queryString): SomeQueryDto
        {
            $query = new self();
            parse_str($queryString, $queryFields);
    
            foreach ($queryFields as $field => $operatorAndValue) {
                [$operator, $value] = explode(':', $operatorAndValue);
                $query->addParameter($field, $operator, $value);
            }
    
            return $query;
        }
    
        public function addParameter(string $field, string $operator, $value): SomeQueryDto
        {
            if (!in_array($field, self::QUERYABLE_FIELDS)) {
                throw new \Exception("$field is invalid query field.");
            }
            if (!array_key_exists($operator, self::STRING_DB_OPERATORS)) {
                throw new \Exception("$operator is invalid query operator.");
            }
            if (!is_scalar($value)) {
                throw new \Exception("$value is invalid query value.");
            }
    
            array_push(
                $this->searchParameters,
                [
                    'field' => $field,
                    'operator' => self::STRING_DB_OPERATORS[$operator],
                    'value' => $value
                ]
            );
    
            return $this;
        }
    
        public function __call($name, $arguments)
        {
            // camelCase to snake_case
            $field = strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $name));
    
            if (in_array($field, self::QUERYABLE_FIELDS)) {
                return $this->addParameter($field, $arguments[1] ?? self::DEFAULT_OPERATOR, $arguments[0]);
            }
        }
    
        public function getSearchParameters()
        {
            return $this->searchParameters;
        }
    }
    

    Example usage:

    $query = new SomeEnitityQuery();
    $query->userId(1)->categoryId(2, 'ne')->createdAt('2020-03-03', 'lte');
    $entities = $someRepository->find($query);
    
    // Or by passing the HTTP query string
    $query = SomeEnitityQuery::buildFromString('created_at=gte:2020-01-01&category_id=in:1,2,3');
    $entities = $someRepository->find($query);
    
    0 讨论(0)
  • 2020-11-29 14:54

    I'll add a bit on this as I am currently trying to grasp all of this myself.

    #1 and 2

    This is a perfect place for your ORM to do the heavy lifting. If you are using a model that implements some kind of ORM, you can just use it's methods to take care of these things. Make your own orderBy functions that implement the Eloquent methods if you need to. Using Eloquent for instance:

    class DbUserRepository implements UserRepositoryInterface
    {
        public function findAll()
        {
            return User::all();
        }
    
        public function get(Array $columns)
        {
           return User::select($columns);
        }
    

    What you seem to be looking for is an ORM. No reason your Repository can't be based around one. This would require User extend eloquent, but I personally don't see that as a problem.

    If you do however want to avoid an ORM, you would then have to "roll your own" to get what you're looking for.

    #3

    Interfaces aren't supposed be hard and fast requirements. Something can implement an interface and add to it. What it can't do is fail to implement a required function of that interface. You can also extend interfaces like classes to keep things DRY.

    That said, I'm just starting to get a grasp, but these realizations have helped me.

    0 讨论(0)
提交回复
热议问题