若是單單是執行 SELECT * FROM test_table;
這樣的語句,使用原生擴展就行了,使用查詢構造器就是殺雞用牛刀。固然,在實際的業務需求中,大部分的 SQL 都沒這麼簡單,有各類條件查詢、分組、排序、連表等操做,尤爲是條件查詢,佔到了查詢業務的大多數。php
這一篇,咱們來說講如何使用查詢構造器進行條件查詢。mysql
首先,咱們回顧一下用 PDO 來寫條件查詢該怎麼作:linux
一、構造語句、預編譯:laravel
PDO 能夠經過佔位符綁定參數,佔位符可使用 :name 的形式或者 ? 的形式。web
$pdoSt = $pdo->prepare("SELECT * FROM test_table WHERE username = :username AND age = :age;");
二、進行參數綁定,執行語句:正則表達式
PDOStatement::bindParam() 和 PDOStatement::bindValue() 方法能夠綁定一個 PHP 變量到指定的佔位符。sql
$username = 'test'; $age = 18; $pdoSt->bindValue(':username', $username, PDO::PARAM_STR); $pdoSt->bindValue(':age', $age, PDO::PARAM_INT); $pdoSt->execute();
由此咱們得知,只要搞定了參數綁定,就能夠構造一個簡單的 where 子句了。數據庫
佔位符選擇:windows
? 佔位符必須按照順序去綁定,而 :name 佔位符只要佔位符和數據的映射關係肯定,綁定的數據就不會出錯。因此咱們選擇 :name 佔位符。數組
綁定方法的選擇:
PDOStatement::bindValue() 方法把一個值綁定到一個參數。
PDOStatement::bindParam() 不一樣於 PDOStatement::bindValue(),綁定變量做爲引用被傳入,並只在 PDOStatement::execute() 被調用的時候才取值。
這裏咱們選擇 PDOStatement::bindValue() 方法,由於參數綁定過程和 execute 執行過程可能被封裝到不一樣的方法中,咱們須要簡單的傳值傳遞而不是引用傳遞。
先回顧下基類如今執行 sql 的過程:在 get()、row() 這些取結果的方法中,先執行構造 sql 的方法,再執行 _execute() 方法執行 sql。那麼也就是說,咱們只要在這兩個方法中間進行參數的綁定便可。
固然,並不是只有 where 子句須要參數綁定,having 子句、where in 子句等也涉及到參數的綁定。爲了程序結構的靈活和清晰,咱們在基類新加一個 _bind_params 屬性,鍵值數組類型,用來存儲佔位符和其綁定數據的映射。這樣,咱們只需:
talk is cheap, just show code:
基類添加 _bind_params 屬性,用於存儲佔位符和其綁定數據的映射:
protected $_bind_params = [];
添加參數綁定方法:
protected function _bindParams() { if(is_array($this->_bind_params)) { // 將佔位符綁定數據數組迭代綁定 foreach ($this->_bind_params as $plh => $param) { // 默認爲字符串類型 $data_type = PDO::PARAM_STR; // 若是綁定數據爲數字 if(is_numeric($param)) { $data_type = PDO::PARAM_INT; } // 若是綁定數據爲 null if(is_null($param)) { $data_type = PDO::PARAM_NULL; } // 若是綁定數據爲 Boolean if(is_bool($param)) { $data_type = PDO::PARAM_BOOL; } // 執行綁定 $this->_pdoSt->bindValue($plh, $param, $data_type); } } }
修改 _execute() 方法:
protected function _execute() { try { $this->_pdoSt = $this->_pdo->prepare($this->_prepare_sql); // 進行參數綁定 $this->_bindParams(); $this->_pdoSt->execute(); $this->_reset(); } catch (PDOException $e) { if($this->_isTimeout($e)) { $this->_closeConnection(); $this->_connect(); try { $this->_pdoSt = $this->_pdo->prepare($this->_prepare_sql); // 進行參數綁定 $this->_bindParams(); $this->_pdoSt->execute(); $this->_reset(); } catch (PDOException $e) { throw $e; } } else { throw $e; } } }
如今咱們開始開發條件查詢的主要對外方法 where()
where 方法應該包含以下的功能:
一、構造 where 子句字符串
咱們但願 where() 方法支持多種條件模式,如 where('name', 'jack')、where('age', '<', '30')、where(['name' => 'jack', 'age' => 18])
。能夠觀察獲得,方法的參數是變更的,那麼咱們可使用可變參數,用 func_num_args() 函數獲得傳入參數的數量進行模式判斷,用 func_get_args() 函數獲得傳入的參數,這樣就能夠實現對多個模式的支持。(固然使用可變參數也是有缺點的,使用可變參數,接口 ConnectorInterface 中就沒法限制該方法參數的個數和類型了,這裏要根據我的需求取捨)
基類增長 where() 方法:
public function where() { // 多個條件的默認鏈接符爲 AND,即與的關係 $operator = 'AND'; // 在一次查詢構造過程當中,是不是第一次調用此方法? // 在鏈式訪問中有效 if($this->_where_str == '') { // 第一次調用,where 子句須要 WHERE 關鍵字 $this->_where_str = ' WHERE '; } else { // 非初次訪問,用鏈接符拼接上一個條件 $this->_where_str .= ' '.$operator.' '; } // 獲取參數數量和參數數組 $args_num = func_num_args(); $params = func_get_args(); // argurment mode switch ($args_num) { case 1: // 只有一個參數:傳入數組,多條件模式,如 a = b AND c = d ... 默認 AND 鏈接 ... break; case 2: // 兩個參數:單條件模式 ... break; case 3: // 三個參數:比較運算符判斷模式 ... break; } // 實現鏈式操做,返回當前實例 return $this; }
二、生成佔位符,保存佔位符和綁定數據的映射
對於 :name 形式的佔位符,只要保證佔位符惟一便可。可是如何保證其惟一性呢?佔位符不光是在 where 子句中出現,還在 where in 、where between 這些須要參數綁定的子句中出現。那麼按照功能和綁定數據拼接字符串來生成嗎?可是問題又來了,對於 where,有 where 和 or where 子句,where in 有 where in、where not in、or where in、or where not in 等組合,多個要綁定的參數也可能擁有相同的值,用功能加綁定數據拼接字符串來生成佔位符很複雜,並且由於和方法、參數自己的依賴度高,無法獨立出來,程序的可維護性也不行。
固然使用 ? 佔位符不用考慮那麼多 (知名框架 laravel 的查詢構造器就是這麼作的,然而源碼太多,原諒我沒時間看完),可是使用 ? 佔位符對參數綁定的順序有很大的要求。對於目前個人程序結構來講,_bindParams() 方法只是一股腦的迭代綁定參數,並不能分清楚各個參數的順序,容易致使綁錯數據的情況。
那麼,就說說我最後決定的作法:使用惟一 ID 生成。
首先將生成佔位符的過程獨立出來,做爲一個獨立方法,這樣即便之後有了更好的方案,也不用更改其餘程序。
使用 PHP 的 uniqid() 函數生成一個惟一字符串,加前綴和熵值 (提升惟一性),用 MD5 籤一下名 (生成 :name 佔位符可接受的字符)。
代碼以下:
// 生成佔位符的方法 // 考慮此方法和類實例自己無關,因此寫爲 static 方法提升效率 protected static function _getPlh() // get placeholder { return ':'.md5(uniqid(mt_rand(), TRUE)); }
性能相關的思考:
Q:uniqid()、mt_rand()、md5() 這些函數的性能如何?會不會拖慢查詢構造器的速度?
A:這幾個函數性能不怎麼樣,可是就像前言那一篇所說的,這些函數的使用的影響是否超過了系統性能的平衡點?對於此查詢構造器程序來說,並非頻繁使用這些函數作密集運算,系統的瓶頸仍是在和數據庫交互的網絡 IO 上,因此,這些函數是可使用的。
注:我在一些測試機上測試過拼接字符串作佔位符和隨機 ID 作佔位符的壓測 AB 對比,並無什麼性能差距。固然可能在一個處理速度超快的服務器、數據庫組合上能看到差距,若是各位有更好的方法歡迎提出,對個人方法的不足也歡迎指正。
Q:能保證生成的佔位符是惟一的嗎?
A:若是在多線程的環境下,存在數據競爭,PHP 又沒有好用的線程庫進行數據加鎖,會出現重複的情況。可是在其餘環境下不會。傳統 web 環境下每次執行隨着一次 HTTP 請求結束而結束,解析 PHP 程序的 PHP-FPM、MOD_PHP 是多進程模型,處理請求時每一個進程中的數據獨立,互不影響。而在 workerman 這個常駐內存的框架裏,多任務也是一個任務開啓一個進程,數據相互獨立,每一個進程中使用 epoll (linux)、select (windows) 來處理併發,並不會出現並行和數據競爭的情況。因此說只要沒有多線程的需求,則佔位符不會重複。
三、支持幾種方便的條件模式
OK,佔位符的生成方式搞定,那麼咱們開始在 where() 方法中使用吧。
public function where() { // 多個條件的默認鏈接符爲 AND,即與的關係 $operator = 'AND'; // 在一次查詢構造過程當中,是不是第一次調用此方法? // 在鏈式訪問中有效 if($this->_where_str == '') { // 第一次調用,where 子句須要 WHERE 關鍵字 $this->_where_str = ' WHERE '; } else { // 非初次訪問,用鏈接符拼接上一個條件 $this->_where_str .= ' '.$operator.' '; } // 獲取參數數量和參數數組 $args_num = func_num_args(); $params = func_get_args(); // 判斷傳入的參數數量是否合法 if( ! $args_num || $args_num > 3) { throw new \InvalidArgumentException("Error number of parameters"); } // argurment mode switch ($args_num) { // 只有一個參數:傳入數組,多條件模式,如 a = b AND c = d ... 默認 AND 鏈接 case 1: if( ! is_array($params[0])) { // 傳入非法參數,拋出異常提醒 throw new \InvalidArgumentException($params[0].' should be Array'); } // 遍歷構造多條件 where 子句 $this->_where_str .= '('; foreach ($params[0] as $field => $value) { $plh = self::_getPlh(); // 生成佔位符 $this->_where_str .= ' '.$field.' = '.$plh.' AND'; // 將佔位符添加到子句字符串中 $this->_bind_params[$plh] = $value; // 保存佔位符和待綁定數據 } // 清除最後一個 AND 鏈接符 $this->_where_str = substr($this->_where_str, 0, strrpos($this->_where_str, 'AND')); $this->_where_str .= ')'; break; // 兩個參數:單條件模式 case 2: if(is_null($params[1])) { // 若是數據爲 null,則使用 IS NULL 語法 $this->_where_str .= ' '.$params[0].' IS NULL '; } else { $plh = self::_getPlh(); // 生成佔位符 $this->_where_str .= ' '.$params[0].' = '.$plh.' '; // 將佔位符添加到子句字符串中 $this->_bind_params[$plh] = $params[1]; // 保存佔位符和待綁定數據 } break; // 三個參數:比較運算符判斷模式 case 3: // 判斷使用的比較運算符是否合法 (各數據庫的運算符支持並不相同) if( ! in_array(strtolower($params[1]), $this->_operators)) { throw new \InvalidArgumentException('Confusing Symbol '.$params[1]); } $plh = self::_getPlh(); // 生成佔位符 $this->_where_str .= ' '.$params[0].' '.$params[1].' '.$plh.' '; // 將佔位符添加到子句字符串中 $this->_bind_params[$plh] = $params[2]; // 保存佔位符和待綁定數據 break; } // where 子句構造完畢 return $this; }
關於上述代碼這裏有幾點要提一下:
在多條件模式下,須要判斷一下傳入的是否是一個數組 (過濾非法參數,方便開發),多個條件之間的鏈接符默認是 AND (大部分多個相等判斷條件之間都是以 AND 的形式鏈接的,若是有 OR 的需求請用鏈式訪問的多個 orWhere() 方法)。
單條件模式下須要判斷綁定數據是否爲 null,若是爲 null,則使用 IS NULL 的語法 (SQL 中不能用 < > = 判斷 null )。
比較運算符判斷模式下,首先要判斷一下傳入的比較運算符是否合法,這裏各個數據庫提供的比較運算符是有差別的,因此咱們要單獨設置一個屬性 _operators 保存這些運算符,Mysql、PostgreSql、Sqlite 這些驅動類中進行重寫。
Mysql 驅動類中:
// Mysql 提供的比較運算符 protected $_operators = [ '=', '<', '>', '<=', '>=', '<>', '!=', '<=>', 'like', 'not like', 'like binary', 'rlike', 'regexp', 'not regexp', '&', '|', '^', '<<', '>>', ];
PostgreSql 驅動類中:
// PostgreSql 提供的比較運算符 protected $_operators = [ '=', '<', '>', '<=', '>=', '<>', '!=', 'like', 'not like', 'ilike', 'similar to', 'not similar to', '&', '|', '#', '<<', '>>', ];
Sqlite 驅動類中:
// Sqlite 提供的比較運算符 protected $_operators = [ '=', '<', '>', '<=', '>=', '<>', '!=', 'like', 'not like', 'ilike', '&', '|', '<<', '>>', ];
打開 test/test.php,修改代碼:
require_once dirname(dirname(__FILE__)) . '/vendor/autoload.php'; use Drivers\Mysql; $config = [ 'host' => 'localhost', 'port' => '3306', 'user' => 'username', 'password' => 'password', 'dbname' => 'database', 'charset' => 'utf8', 'timezone' => '+8:00', 'collection' => 'utf8_general_ci', 'strict' => false, ]; $driver = new Mysql($config); // 單條件模式測試 $results = $driver->table('test_table') ->select('*') ->where('username', 'jack') ->get(); var_dump($results); // 鏈式訪問 + 比較運算符判斷模式測試 $results = $driver->table('test_table') ->select('*') ->where('username', 'jack') ->where('age', '<', 30) ->get(); var_dump($results); // 多條件模式測試 $results = $driver->table('test_table') ->select('*') ->where([ 'username' => 'jack', 'age' => 18, ]) ->get(); var_dump($results);
執行看看,數據是否是如你所想。
雖然完成了 where() 方法的編寫,可是咱們發現 where() 方法中的代碼很臃腫,並且後續編寫 orWhere()、having() 這些都須要用到條件查詢的方法時,不少的代碼都是重複的。既然如此,那麼就把這部分代碼提出來。
基類添加 _condition_constructor 方法:
// $args_num 爲 where() 傳入參數的數量 // $params 爲 where() 傳入的參數數組 // $construct_str 爲要構造的子句的字符串,在 where() 方法中調用會傳入 $this->_where_str // 由於要改變該子句字符串,因此這裏使用引用傳遞 protected function _condition_constructor($args_num, $params, &$construct_str) { if( ! $args_num || $args_num > 3) { throw new \InvalidArgumentException("Error number of parameters"); } switch ($args_num) { case 1: if( ! is_array($params[0])) { throw new \InvalidArgumentException($params[0].' should be Array'); } $construct_str .= '('; foreach ($params[0] as $field => $value) { $plh = self::_getPlh(); $construct_str .= ' '.$field.' = '.$plh.' AND'; $this->_bind_params[$plh] = $value; } $construct_str = substr($construct_str, 0, strrpos($construct_str, 'AND')); $construct_str .= ')'; break; case 2: if(is_null($params[1])) { $construct_str .= ' '.$params[0].' IS NULL '; } else { $plh = self::_getPlh(); $construct_str .= ' '.$params[0].' = '.$plh.' '; $this->_bind_params[$plh] = $params[1]; } break; case 3: if( ! in_array(strtolower($params[1]), $this->_operators)) { throw new \InvalidArgumentException('Confusing Symbol '.$params[1]); } $plh = self::_getPlh(); $construct_str .= ' '.$params[0].' '.$params[1].' '.$plh.' '; $this->_bind_params[$plh] = $params[2]; break; } }
修改後的 where() 方法:
public function where() { // 多個條件的默認鏈接符爲 AND,即與的關係 $operator = 'AND'; // 在一次查詢構造過程當中,是不是第一次調用此方法? // 在鏈式訪問中有效 if($this->_where_str == '') { // 第一次調用,where 子句須要 WHERE 關鍵字 $this->_where_str = ' WHERE '; } else { // 非初次訪問,用鏈接符拼接上一個條件 $this->_where_str .= ' '.$operator.' '; } // 進行佔位符生成、參數綁定、生成子句字符串操做 $this->_condition_constructor(func_num_args(), func_get_args(), $this->_where_str); return $this; }
這樣咱們就把能夠通用的邏輯提出來了,趁熱打鐵,咱們把 orWhere() 方法也添加到基類中。
對於 orWhere() 方法,和 where() 方法的區別只有鏈式操做進行多條件查詢時的鏈接符不一樣:
public function orWhere() { $operator = 'OR'; if($this->_where_str == '') { $this->_where_str = ' WHERE '; } else { $this->_where_str .= ' '.$operator.' '; } $this->_condition_constructor(func_num_args(), func_get_args(), $this->_where_str); return $this; }
構造語句 SELECT * FROM test_table WHERE username = 'jack' OR username = 'mike';
:
$results = $driver->table('test_table') ->select('*') ->where('username', 'jack') ->orWhere('username', 'mike') ->get();
熟悉數據庫的朋友們應該知道,每種數據庫都有一些關鍵字,一部分是 SQL 語句的關鍵字,另外一部分是數據庫本身的關鍵字。既然有關鍵字,那麼就避免不了用戶鍵入的數據和關鍵字重名的問題,好比表名和關鍵字重名、字段名 (別名) 和關鍵字重名等。
那麼如何解決關鍵字衝突呢?
固然,建表的時候儘可能注意命名,不要和關鍵字衝突是一種方法,可是若是這個表的創建、修改權限不在你手中,你又要訪問這個表去拿數據的時候就沒招了,因此咱們經常要對歷史遺留問題進行兼容處理。
各數據庫使用了相似轉義的作法。Mysql 使用反引號 ` 來包裹字符串避免數據庫將這個字符解析爲關鍵字,PostgreSql 和 Sqlite 則是用雙引號 " 來作相應的工做。
而在使用查詢構造器的過程當中,總不能每次由用戶手動來寫這個符號 (如 where('`count`', 12) ),這樣更換數據庫驅動的時候會影響到上層的代碼,可維護性差 (如 mysql 切到 pgsql,須要把全部 ` 改成 " )。因此,爲可能出現關鍵字衝突的地方添加引號應該交給查詢構造器底層去作。
既然各個數據庫有差別,想必如今你們已經知道該怎麼作了,基類添加屬性 _quote_symbol,Mysql 類中進行重寫。
Mysql 驅動類中添加:
// 由於次屬性不會改變,使用 static 關鍵字 protected static $_quote_symbol = '`';
PostgreSql 和 Sqlite 同理,這裏不單獨演示了。
下面咱們給基類添加 _quote() 方法,用於給字符串添加引號:
// static 方法 protected static function _quote($word) { return static::$_quote_symbol.$word.static::$_quote_symbol; }
有了這個方法,咱們能夠簡單的防止一個字符串關鍵字衝突了。可是在實際應用中還遠不夠。
首先,在書寫 SQL 時字段的表述有不少模式
咱們必須的對這些經常使用情形作處理,而不僅是直接對這些字符串的兩邊加引號。
對字符串的匹配處理,那麼咱們首先想到的是正則表達式。至於正則的性能,仍是參考前言所說的性能平衡點,這裏每次請求用到正則的次數不多,並無突破數據庫鏈接和執行的網絡 IO 瓶頸,因此可使用。
在基類添加 _wrapRow 方法,用來處理 SQL 字段的字符串:
// static 方法 protected static function _wrapRow($str) { // 匹配模式 $alias_pattern = '/([a-zA-Z0-9_\.]+)\s+(AS|as|As)\s+([a-zA-Z0-9_]+)/'; $alias_replace = self::_quote('$1').' $2 '.self::_quote('$3'); $prefix_pattern = '/([a-zA-Z0-9_]+\s*)(\.)(\s*[a-zA-Z0-9_]+)/'; $prefix_replace = self::_quote('$1').'$2'.self::_quote('$3'); $func_pattern = '/[a-zA-Z0-9_]+\([a-zA-Z0-9_\,\s\`\'\"\*]*\)/'; // alias mode 別名模式 if(preg_match($alias_pattern, $str, $alias_match)) { // 若是列是 aa.bb as cc 的模式 if(preg_match($prefix_pattern, $alias_match[1])) { $pre_rst = preg_replace($prefix_pattern, $prefix_replace, $alias_match[1]); $alias_replace = $pre_rst.' $2 '.self::_quote('$3'); } // 若是列是 aa as bb 的模式 return preg_replace($alias_pattern, $alias_replace, $str); } // prefix mode 表.字段 模式 if(preg_match($prefix_pattern, $str)) { return preg_replace($prefix_pattern, $prefix_replace, $str); } // func mode 函數模式,什麼都不作,交給用戶去處理 if(preg_match($func_pattern, $str)) { return $str; } // field mode 簡單的字段模式,直接加引號返回 return self::_quote($str); }
上訴代碼有幾點要說明:
別名模式是最複雜的,須要判斷是 aa as bb 模式仍是 aa.bb as cc 模式,匹配替換後的結果是 `aa` as `bb` 、`aa`.`bb` as `cc` (這裏以 mysql 爲例)。
函數模式如 count(aa.cc)、max(count) 這種,函數的參數數量不定,模式多變很差匹配,交給用戶手動輸入原生字符串去處理,並且諸如此類的聚合函數的話,後面的篇幅會增長聚合函數的相關方法去得到結果。
有了 _wrapRow() 方法,咱們可使關鍵字衝突的處理對上層應用徹底透明。
修改 table() 方法:
public function table($table) { // 添加引號 $this->_table = self::_wrapRow($table); return $this; }
修改 select() 方法:
public function select() { $cols = func_get_args(); if( ! func_num_args() || in_array('*', $cols)) { $this->_cols_str = ' * '; } else { $this->_cols_str = ''; foreach ($cols as $col) { // 添加引號 $this->_cols_str .= ' '.self::_wrapRow($col).','; } $this->_cols_str = rtrim($this->_cols_str, ','); } return $this; }
修該用於條件構造的 _condition_constructor() 方法:
protected function _condition_constructor($args_num, $params, &$construct_str) { if( ! $args_num || $args_num > 3) { throw new \InvalidArgumentException("Error number of parameters"); } switch ($args_num) { case 1: if( ! is_array($params[0])) { throw new \InvalidArgumentException($params[0].' should be Array'); } $construct_str .= '('; foreach ($params[0] as $field => $value) { $plh = self::_getPlh(); // 添加引號 $construct_str .= ' '.self::_wrapRow($field).' = '.$plh.' AND'; $this->_bind_params[$plh] = $value; } $construct_str = substr($construct_str, 0, strrpos($construct_str, 'AND')); $construct_str .= ')'; break; case 2: if(is_null($params[1])) { // 添加引號 $construct_str .= ' '.self::_wrapRow($params[0]).' IS NULL '; } else { $plh = self::_getPlh(); // 添加引號 $construct_str .= ' '.self::_wrapRow($params[0]).' = '.$plh.' '; $this->_bind_params[$plh] = $params[1]; } break; case 3: if( ! in_array(strtolower($params[1]), $this->_operators)) { throw new \InvalidArgumentException('Confusing Symbol '.$params[1]); } $plh = self::_getPlh(); // 添加引號 $construct_str .= ' '.self::_wrapRow($params[0]).' '.$params[1].' '.$plh.' '; $this->_bind_params[$plh] = $params[2]; break; } }
如今咱們給要查的數據表中添加一個名爲 group 的字段,構造一下 SELECT * FROM test_table where group = 'test';
這個語句,看是否會報錯呢?
$results = $driver->table('test_table') ->select('*') ->where('group', 'test') ->get();
Just do it!