+16

[PHP] Xây dựng một "Mini Query Builder" từ PHP-PDO

Giới thiệu

Nói về phương pháp học tập cũng như làm việc thì muôn màu muôn vẻ. Mỗi người sẽ có một cách thức riêng biệt để tiếp cận và thu nhận kiến thức phù hợp cho mình. Bản thân tôi và bạn bè của mình cũng thường chia sẻ về điều này. Có người thì nghĩ rằng không cần lý thuyết, cứ làm nhiều rồi sẽ nhớ. Có người lại cho rằng cần đọc trước lý thuyết một chút thôi, rồi làm, rồi chiêm nghiệm thì mới hiệu quả. Còn cá nhân tôi, tôi lại cảm thấy lý thuyết vô cùng quan trọng, đọc được càng nhiều thì càng tốt, tôi luôn cố gắng đọc lý thuyết nhiều nhất, thậm chí có những phần đọc đi đọc lại đến 4, 5 lần cho đến mức càng đọc càng không hiểu mới thôi (Dĩ nhiên là có kèm thực hành trong khi đọc nhé 😄).

Vậy thì phương pháp học nào là hiệu quả nhất? Tôi nghĩ rằng cái nào cũng tốt, mỗi phương pháp học thì chỉ hiệu quả cho một hoặc một số người mà thôi. Trong bài viết này, tôi sẽ chia sẻ cho các bạn một cách trong các phương pháp học tập mà tôi cảm thấy hay nhất.

Tổng quan

Chắc hẳn "query builder" là một khái niệm quá phổ biến? Có thể, một số công nghệ hay framework nào đó có những cách gọi khác nhau. Nhưng dù sao đi nữa thì nó cũng dùng để nói về một kỹ thuật mà giúp lập trình viên xây dựng ra những câu truy vấn CSDL Quan Hệ một cách nhanh chóng và chính xác thay vì phải tự viết truy vấn. Bản thân tôi là một lập trình viên web php, khi mới tiếp cận với php framework đầu tiên là Codeigniter, tôi đã cảm thấy vô cùng hấp dẫn và phấn khích bởi kỹ thuật này. Tôi luôn tự đặt câu hỏi là tại sao họ có thể làm nó tuyệt vời đến vậy? Và vào thời điểm ấy, cách đây khoảng hơn 1 năm về trước, anh chàng sinh viên năm 3 như tôi đã cày ngày cày đêm, quên ăn quên ngủ để tự viết một cái query builder cho mình. Dĩ nhiên là chỉ mang tính chất nghiên cứu thôi nhé 😃). Chúng ta cùng bắt đầu thôi nào.

Phân tích

Tôi sẽ không nhắc đến nhưng câu truy vấn CSDL Quan hệ vì những ai học lập trình thì đều đã sử dụng loại CSDL này và mặc định có những hiểu biết cơ bản về nó. Tôi sẽ tập trung vào việc dùng code để tạo ra những câu truy vấn. Về việc triển khai query builder theo cách nào thì tôi đều xác định 3 thành phần như thế này:

  • Database Connection: Thành phần dùng để kết nối với CSDL, thực hiện truy vấn và lấy dữ liệu.
  • Query Builder: Thành phần giúp xây dựng câu truy vấn từ những tham số truyền vào.
  • Query Result: Thành phần nhận kết quả từ truy vấn và chuyển đối sang dữ liệu phù hợp.

Hiện thực bằng mã nguồn php

1) Xây dựng Database Connection.

  • Nói là xây dựng thì nghe cũng có vẻ hơi to tát nhỉ? Vì tôi sử dụng luôn PDO mà PDO thì "full power" luôn rồi còn đâu 😃) Nhóm các class PDO của php đã giúp làm tất cả mọi việc từ kết nối, truy vấn cũng như lấy kết quả theo mong muốn. Chúng ta chỉ cần viết một connection cực kì đơn giản là đủ làm mọi thứ với CSDL rồi.

  • Tôi sử dụng mysql nhé. Nếu bạn sử dụng loại CSDL khác thì phải tự kích hoạt phần mở rộng (php extensions) cho loại PDO với CSDL đó http://php.net/manual/en/pdo.drivers.php. Cụ thể tôi sẽ viết một class connection như sau:

<?php

namespace App\Database;

use PDO;

class MySqlConnection
{
    // PDO Object
    protected $pdo;

    // create a new connection instance.
    public function __construct(array $config)
    {
        $this->open($config);
    }

    // destroy an exists connection instance.
    public function __destruct()
    {
        $this->close();
    }

    // open connection
    private final function open(array $config)
    {
        $this->pdo = new PDO(
            $this->getConnectionString($config),
            $config['username'],
            $config['password']
        );
    }

    // execute a sql query.
    public final function execute($sql)
    {
        return $this->pdo->exec($sql);
    }

    // send a sql query to get results.
    public final function query($sql, array $params = [])
    {
        if (! empty($params)) {
            $sth = $this->pdo->prepare($sql);
            $sth->execute($params);
            return $sth;
        }

        return $this->pdo->query($sql);
    }

    // close connection.
    private final function close()
    {
        $this->pdo = null;
    }

    // get the connection string with format 'mysql:dbname=...;host=...;'
    protected function getConnectionString(array $config)
    {
        return 'mysql:dbname=' . $config['dbname'] . ';host=' . $config['hostname'];
    }
}

2. Xây dựng Query Builder.

  • Đây là bước trọng tâm, cái này làm tốn thời gian nhất. Một câu truy vấn sql tổng thể sẽ gồm rất nhiều mệnh đề như select, from, where, group by, having, order by, limit, offset, union. Vậy thì chắc chắn một base query builder tối thiểu phải có đủ các cách thức để hỗ trợ tạo ra những mệnh đề trên rồi đúng không nào? Và như vậy, tôi đã viết một SqlClauses như thế này:
<?php

namespace App\Database\Query;

trait SqlClauses
{
    /**
     * Select clause parameters.
     *
     * @var array
     */
    protected $select = [];

    /**
     * From clause parameters.
     *
     * @var array
     */
    protected $from = [];

    /**
     * Where clause parameters.
     *
     * @var array
     */
    protected $where = [];

    /**
     * Group By clause parameters.
     *
     * @var array
     */
    protected $groupBy = [];

    /**
     * Having clause parameters.
     *
     * @var array
     */
    protected $having = [];

    /**
     * Order By clause parameters.
     *
     * @var array
     */
    protected $orderBy = [];

    /**
     * Limit Clause parameters.
     *
     * @var integer
     */
    protected $limit;

    /**
     * Offset clause parameters.
     *
     * @var integer
     */
    protected $offset;

    /**
     * Apply select clause.
     *
     * @param  mixed  $fields
     * @return $this
     */
    public function select($fields = ['*'])
    {
        $fields = is_array($fields) ? $fields : func_get_args();

        $this->addSelect($fields);

        return $this;
    }

    /**
     * Apply from clause.
     *
     * @param  mixed  $tables
     * @return $this
     */
    public function from($tables)
    {
        $tables = is_array($tables) ? $tables : func_get_args();

        $this->addFrom($tables);

        return $this;
    }

    /**
     * Apply where clause.
     *
     * @param  array  $params
     * @return $this
     */
    public function where(...$params)
    {
        return $this->andWhere(...$params);
    }

    /**
     * Apply where clause with and.
     *
     * @param  array  $params
     * @return $this
     */
    public function andWhere(...$params)
    {
        return $this->whereLogicOperator('and', ...$params);
    }

    /**
     * Apply where clause with or.
     *
     * @param  array  $params
     * @return $this
     */
    public function orWhere(...$params)
    {
        return $this->whereLogicOperator('or', ...$params);
    }
}

=> Lưu ý: mục đích của tôi là tìm hiểu cách để tạo ra query builder nên tôi không hoàn thiện mà chỉ viết cho một vài mệnh đề cơ bản. Ở đây, tôi chỉ làm đến where với một số điều kiện cơ bản.

  • Theo đó, tôi tạo ra một BaseSqlBuilder và MySqlBuilder:
<?php

namespace App\Database\Query;

use PDO;

abstract class BaseSqlBuilder
{
    use SqlClauses;

    /**
     * The database connection object.
     *
     * @var mixed
     */
    protected $connection;

    /**
     * The type of data that should be fetched from results.
     *
     * @var integer
     */
    protected $fetchType = PDO::FETCH_OBJ;
   
    /**
     * The insentitive case for database type.
     *
     * @var string
     */ 
    protected $insensitive;

    /**
     * The logic operators (and, or).
     *
     * @var array
     */
    protected $logicOperatorss = [
        'or',
        'and',
    ];

    /**
     * Available comparisons for where clause.
     */
    protected $comparisons = [
        'equal' => '=',
        'not_equal' => '<>',
        'not_equal_other' => '!=',
        'less' => '<',
        'less_or_equal' => '<=',
        'greater' => '>',
        'greater_or_equal' => '>=',
        'like' => 'like',
        'in' => 'in',
        'not_in' => 'not in',
        'between' => 'between',
        'not_between' => 'not between',
    ];

    /**
     * Create a new query builder instance.
     *
     * @param  mixed  $connection
     * @return void
     */
    public function __construct($connection)
    {
        $this->connection = $connection;
    }

    /**
     * Get all records from query results.
     *
     * @return array
     */
    public function all()
    {
        $sql = $this->getCompiledSelectStatement();
        
        return $this->connection->query($sql)->fetchAll($this->fetchType);
    }

    /**
     * Get the first record from result.
     *
     * @return mixed
     */
    public function first()
    {
        $sql = $this->getCompiledSelectStatement();

        return $this->connection->query($sql)->fetch($this->fetchType);
    }

    /**
     * Add fields to the select clause.
     *
     * @param  array  $fields
     * @return void
     */
    protected function addSelect(array $fields)
    {
        foreach ($fields as $key => $field) {
            $fields[$key] = $this->identifierOf($field);
        }

        $this->select = array_merge($this->select, $fields);
    }

    /**
     * Get the compiled select clause.
     *
     * @return string
     */
    protected function getCompiledSelectClause()
    {
        return 'select ' . implode(', ', $this->select);
    }

    /**
     * Add tables for from clause.
     *
     * @param  array  $tables
     * @return void
     */
    protected function addFrom(array $tables)
    {
        foreach ($tables as $key => $table) {
            $tables[$key] = $this->identifierOf($table);
        }

        $this->from = array_merge($this->from, $tables);
    }

    /**
     * Get the compiled from clause.
     *
     * @return string
     */
    protected function getCompiledFromClause()
    {
        return 'from ' . implode(', ', $this->from);
    }

    /**
     * Add condition for where clause.
     *
     * @param  string  $field
     * @param  string  $operator
     * @param  mixed  $value
     * @param  string  $logicOperators
     * @return void
     */
    protected function addWhere($field, $operator, $value, $logicOperator = 'and')
    {
        if (! in_array($operator, $this->comparisons)) {
            die("$operator is invalid operator.");
        }

        switch ($operator) {
            case $this->comparisons['in']:
            case $this->comparisons['not_in']:
                $value = $this->getValueForInClause($value);
                break;
            case $this->comparisons['between']:
            case $this->comparisons['not_between']:
                $value = $this->getValueForBetweenClause($value);
                break;
            default:
        }

        $field = $this->identifierOf($field);

        $this->where[] = [
            'logic_operator' => $logicOperator,
            'params' => compact('field', 'operator', 'value'),
        ];
    }

    /**
     * Add condition with logic operator to where clause.
     *
     * @param  string  $logicOperator
     * @param  array  $params
     * @return $this
     */
    protected function whereLogicOperator($logicOperator, ...$params)
    {
        list($field, $operator, $value) = $this->getParseWhereParameters($params);

        $this->addWhere($field, $operator, $value, $logicOperator);

        return $this;
    }

    /**
     * Get parse where parameters.
     *
     * @param  array  $params
     * @return array
     */
    protected function getParseWhereParameters(array $params)
    {
        if (count($params) === 3) {
            return $params;
        }
        
        if (count($params) === 2) {
            return [$params[0], $this->comparisons['equal'], $params[1]];
        }

        die('Not valid where parameters.');
    }

    /**
     * Get the compiled where clause.
     *
     * @return string
     */
    protected function getCompiledWhereClause()
    {
        if (empty($this->where)) {
            return '';
        }

        $conditions = '';

        foreach ($this->where as $key => $where) {
            $conditions .= $where['logic_operator'] . ' '
                . $where['params']['field'] . ' '
                . $where['params']['operator'] . ' '
                . $where['params']['value'] . ' ';
        }

        $conditions = '(' . ltrim(ltrim($conditions, 'or'), 'and') . ')';

        return 'where ' . trim($conditions);
    }

    /**
     * Get the identifier of a field or a table.
     *
     * @param  string  $identifier
     * @return string
     */
    protected function identifierOf($identifier)
    {
        $identifier = strtolower($identifier);

        if (preg_match('/^(.+) as (.+)$/', $identifier)) {
            return $this->identifierWithAsKeywordOf($identifier);
        }

        if (strpos($identifier, '.') !== false) {
            return $this->identifierWithDotOf($identifier);
        }

        return $this->insensitive . trim($identifier) . $this->insensitive;
    }

    /**
     * Get the identifier (include as keyword) of a field or a table.
     *
     * @param  string  $identifier
     * @return string
     */
    protected function identifierWithAsKeywordOf($identifier)
    {
        $data = explode(' as ', $identifier);

        if (count($data) == 2) {
            $baseField = $this->identifierOf($data[0]);
            $aliasField = $this->identifierOf($data[1]);

            return $baseField . ' as ' . $aliasField;
        }

        die($identifier . ' is invalid.');
    }

    /**
     * Get the identifier (include as dot) of a field or a table.
     *
     * @param  string  $identifier
     * @return string
     */
    protected function identifierWithDotOf($identifier)
    {
        $data = explode('.', $identifier);

        if (count($data) == 2) {
            $table = $this->identifierOf($data[0]);
            $field = $this->identifierOf($data[1]);

            return $table . '.' . $field;
        }

        die($identifier . ' is invalid.');
    }

    /**
     * Get compiled sql statement.
     *
     * @return string
     */
    public function getCompiledSelectStatement()
    {
        $clauses['select'] = $this->getCompiledSelectClause();
        $clauses['from'] = $this->getCompiledFromClause();
        $clauses['where'] = $this->getCompiledWhereClause();

        $this->clearAllClauses();

        return implode(' ', $clauses);
    }

    /**
     * Get value of in or not in comparison.
     *
     * @param  array  $values
     * @return string
     */
    protected function getValueForInClause(array $values)
    {
        return '(' . implode(', ', $values) . ')';
    }

    /**
     * Get value for between comparison.
     *
     * @param  array  $values
     * @return string
     */
    protected function getValueForBetweenClause(array $values)
    {
        if (count($values) !== 2) {
            die('This is invalid for between clause.');
        }

        return $values[0] . ' and ' . $values[1];
    }

    /**
     * Clear all clauses.
     *
     * @return void
     */
    protected function clearAllClauses()
    {
        $this->select = [];
        $this->from = [];
        $this->where = [];
    }
}

<?php

namespace App\Database\Query;

class MySqlBuilder extends BaseSqlBuilder
{
    protected $insensitive = '`';
}

  1. Xây dựng Query Result
  • Để rút ngắn các công đoạn nên tôi đã tóm gọn tên này vào connection builder rồi. Mọi người có thể xem lại phương thức all và first class BaseSqlBuilder nhé.

  • Bây giờ, hãy thử một số kết quả nhé. Các thông số database, tôi đã ghi khá rõ ràng trong file, các bạn có thể tự tạo CSDL và bảng khác để thử nhé:

<?php

// include all example files.
include 'app/Database/Query/SqlClauses.php';
include 'app/Database/Query/BaseSqlBuilder.php';
include 'app/Database/Query/MySqlBuilder.php';
include 'app/Database/MySqlConnection.php';

// use short class name.
use App\Database\MySqlConnection;
use App\Database\Query\MySqlBuilder;

// create a new mysql connection.
$connection = new MySqlConnection([
    'dbname' => 'test',
    'hostname' => '127.0.0.1',
    'username' => 'root',
    'password' => '[email protected]#',
]);

// create a new mysql instance.
$builder = new MySqlBuilder($connection);

// test an example.
$user = $builder
    ->select('id', 'fullname')
    ->where('id', 1)
    ->from('users')
    ->first();

print_r($user);

// get a compiled select.
$sql = $builder->select('id', 'fullname')
    ->from('users')
    ->where('id', '=', 3)
    ->getCompiledSelectStatement();

print_r($sql);

Kết luận

Như vậy là chúng ta đã làm được một "Mini Query Builder" dựa trên PDO của php. Khá thú vị phải không? 😃) Như đã chia sẻ, đây chính là phương pháp học của tôi, đó là "Tự tay làm lại những kỹ thuật đã có" để hiểu hơn về cách hoạt động của nó. Việc này đã giúp tôi rất nhiều trong quá trình tiếp thu một lượng kiến thức mới về công nghệ lập trình, hiểu sâu hơn về các framework. Chúc mọi người tìm được những phương pháp thích hợp cho riêng mình. Tham khảo: http://php.net/manual/en/book.pdo.php


All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.