前面的链接:
前面我们把整个编译器的算法讲解完成了。Parser类编译完成以后是一个PHP程序可用的数组。但是,为了对程序员友好,我们最后把数据封装到了Query类中。为什么要这么做呢,这是因为,对于完整的查询或者是查询语句片断,PHP程序使用时是要区别对待的。其实,任何一个开源,如果你对程序员不友好,那就是死路一条。这也就是为什么Laravel框架能在短时间内成为世界第一,因为,它真的是好用。
其实,Query类,也并没有做什么,它就是实现了QueryInterface这个接口。
declare(strict_types=1);
/*
* This file is part of the ByteFerry/Rql-Parser package.
*
* (c) BardoQi <67158925@qq.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace ByteFerry\RqlParser;
/**
* Interface QueryInterface
*
* @package ByteFerry\RqlParser
*
* @property array $container
*/
interface QueryInterface
{
/**
* @return QueryInterface
*/
public static function of();
/**
* @param array $query
*
* @return \ByteFerry\RqlParser\QueryInterface
*/
public function from($query);
/**
* @param $name
*
* @return mixed|null
*/
public function __get($name);
/**
* @param $name
*
* @return bool
*/
public function __isset($name);
/**
* @return mixed
*/
public function toArray();
}
而实现这个接口的有两个类,一个叫Fragment,它是RQL查询片段。
declare(strict_types=1);
/*
* This file is part of the ByteFerry/Rql-Parser package.
*
* (c) BardoQi <67158925@qq.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace ByteFerry\RqlParser;
use ByteFerry\RqlParser\Abstracts\BaseObject;
/**
* Class Fragment
*
* @package ByteFerry\RqlParser
*/
class Fragment extends BaseObject implements QueryInterface
{
/**
* @var array
*/
protected $container = [];
/**
* @param array $query
*
* @return \ByteFerry\RqlParser\Fragment
*/
public function from($query)
{
$this->container = $query;
return $this;
}
/**
* @param $name
*
* @return mixed|null
*/
public function __get($name)
{
if (isset($this->container[$name])) {
return $this->container[$name];
}
return null;
}
/**
* @param $name
*
* @return bool
*/
public function __isset($name)
{
return isset($this->container[$name]);
}
/**
* @return array|mixed
*/
public function toArray(){
return $this->container;
}
}
另一个则是Query,Query对象提供了
declare(strict_types=1);
/*
* This file is part of the ByteFerry/Rql-Parser package.
*
* (c) BardoQi <67158925@qq.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace ByteFerry\RqlParser;
use ByteFerry\RqlParser\Abstracts\BaseObject;
/**
* Class Query
*
* @package ByteFerry\RqlParser
*/
class Query extends BaseObject implements QueryInterface
{
/**
* @var array
*/
protected $container = [];
/**
* @param array $query
*
* @return \ByteFerry\RqlParser\Query
*/
public function from($query)
{
$this->container = $query;
return $this;
}
/**
* @param $name
*
* @return mixed|null
*/
public function __get($name){
if(isset($this->container[$name])){
return $this->container[$name];
}
return null;
}
/**
* @param $name
*
* @return bool
*/
public function __isset($name){
return isset($this->container[$name]);
}
/**
* @return array|mixed
*/
public function toArray(){
return $this->container;
}
/**
* @return mixed|null
*/
public function getOperator(){
return $this->container['operator']??null;
}
/**
* @return mixed|null
*/
public function getQueryType(){
return $this->container['query_type']??null;
}
/**
* @return string
*/
public function getResourceName(){
return $this->container['resource']??null;
}
/**
* @return array
*/
public function getColumns(){
return $this->container['columns']??null;
}
/**
* @return mixed|null
*/
public function getColumnsOperator(){
return $this->container['columns_operator']??null;
}
/**
* @return mixed|null
*/
public function getGroupBy(){
return $this->container['group_by']??null;
}
/**
* @return mixed|null
*/
public function getFilter(){
return $this->container['filter'][0]??null;
}
/**
* @return mixed|null
*/
public function getParameters(){
return $this->container['paramaters']??null;
}
/**
* @return mixed|null
*/
public function getSearch(){
return $this->container['search']??null;
}
/**
* @return mixed|null
*/
public function getSort(){
return $this->container['sort']??null;
}
/**
* @return mixed|null
*/
public function getHaving(){
return $this->container['having'][0]??null;
}
/**
* @return mixed|null
*/
public function getLimit(){
return $this->container['limit']??null;
}
项目当中,剩下的两个类,那就是BaseObject 和 ParseException,无需讲解。接下来,就是单元测试了。
为什么要单元测试?假如你写的组件,那是交给天下人用的。如果未经测试,谁愿意做你的小白鼠?使用单元测试,我可以用composer下载之后,可以通过PhpUnit命令行验证一下。为什么要验证?因为,php版本,关联组件版本可能不一样。就象一些公司,说明,有新版本了,于是,尽快升级,但是,一升级,马上就崩了。这种情况相当常见,毕境,一旦有关联组件,肯定就有兼容问题,比如,thinkphp5.0就不兼容现在的redis,redis把函数名改了,del改成delete了。这还是小事。像thinkphp5.0与thinkphp5.1直接未在composer中设置版本冲突,所以,你在用5.0时,一不小心升级了,于是崩了。再如,thinkphp5系列均不带PhpUnit,所以,当我安装了组件之后,我无法验证可不可运行。只有在开发了,代码出来以后,才开始报错,这就浪费了大量的时间。所以,一般我composer安装后,喜欢用PhpUnit验证一下之后再开发,如果不行,则换版本,或换组件。
由此来看,你把你的想法实现了,交给大家来用,这是开源精神,值得称赞,但是,实际上,github中大量的开源并不是按此规范做的。所以,那些开源基本无人问津。
那么,该怎么测呢?首先我们要安装PhpUnit 和 orchestra/testbench,为什么要安装orchestra/testbench呢?这是为了能和travisCI这个免费的持续集成工具集成方便。另外,写代码也会方便很多。
关于这两个组件的使用,不多讲了,大家还是直接看文档。基本步骤是,配置phpuint.xml,用以告诉它,怎么测。然后,继承一个TestCase,废话少说,上代码:
declare(strict_types=1);
/*
* This file is part of the ByteFerry/Rql-Parser package.
*
* (c) BardoQi <67158925@qq.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace ByteFerry\Tests;
use Orchestra\Testbench\TestCase as Orchestra;
/**
* Class TestCase
*
* @package ByteFerry\tests
*/
class TestCase extends Orchestra
{
/**
* The base URL to use while testing the application.
*
* @var string
*/
protected $baseUrl = 'http://localhost';
public function setUp(): void
{
// touch('./tests.sqlite');
parent::setUp();
}
public function tearDown(): void
{
// unlink('./tests.sqlite');
}
/**
* Define environment setup.
*
* @param \Illuminate\Foundation\Application $app
*
* @return void
*/
protected function getEnvironmentSetUp($app)
{
// Setup default database to use sqlite :memory:
$app['config']->set('database.default', 'testbench');
$app['config']->set('database.connections.testbench', [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
]);
}
}
这个类的代码基本都是默认的。接下来,要写相关测试了。 首先,我们要测RQL的所有关键字。
/*
* This file is part of the ByteFerry/Rql-Parser package.
*
* (c) BardoQi <67158925@qq.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace ByteFerry\Tests\Unit;
use ByteFerry\Tests\TestCase;
use ByteFerry\RqlParser\Parser;
/**
* Class KeyTest
*
* @package ByteFerry\tests\Unit
*/
final class KeyTest extends TestCase
{
protected $test_keys = [
'aggr' =>['aggr(id,sum(amount))','{"columns":["id","sum(amount)"],"columns_operator":"aggr","group_by":["id"]}'],
'aggregate' =>['aggregate(id,sum(amount))','{"columns":["id","sum(amount)"],"columns_operator":"aggregate","group_by":["id"]}'],
'and' =>['and(gt(age,20),lt(id,300),is(name,null()))','"( age > 20 )and( id < 300 )and( name is null )"'],
'arr' =>['in(id,(1,34,2,4))','" id in (1, 34, 2, 4) "'],
'avg' =>['aggr(id,avg(amount))', '{"columns":["id","avg(amount)"],"columns_operator":"aggr","group_by":["id"]}'],
'between' =>['between(age,18,55)','" age BETWEEN 18 and 55 "'],
'cols' =>['cols(id,age)','{"columns":["id","age"],"columns_operator":"cols","group_by":[]}'],
'columns' =>['columns(id,age,name)','{"columns":["id","age","name"],"columns_operator":"columns","group_by":[]}'],
'data' =>['data(id:17,score:33)','{"data":{"id":"17","score":"33"}}'],
'empty' => ['is(name,empty())','" name is \"\" "'],
'eq' =>['eq(age,17)','" age = 17 "'],
'except' =>['except(password)','{"columns":["password"],"columns_operator":"except","group_by":[]}'],
'false' => ['filter(false())','{"filter":[0],"paramaters":[]}'],
'filter' =>['filter(eq(age,11))','{"filter":[" age = 11 "],"paramaters":{"age":"11"}}'],
'ge' =>['ge(age,35)','" age >= 35 "'],
'gt' =>['gt(age,15)','" age > 15 "'],
'having' =>['having(ge(sum(amount),100))','{"having":[" sum(amount) >= 100 "],"paramaters":{"sum(amount)":"100"}}'],
'in' =>['in(id,(1,3,5))','" id in (1, 3, 5) "'],
'is' => ['is(name,null())','" name is null "'],
'le' =>['le(age,3)','" age <= 3 "'],
'like' =>['like(name,"ad%")','" name like \"ad%\" "'],
'limit' =>['limit(1,20)','{"limit":["1","20"]}'],
'lt' =>['lt(age,15)','" age < 15 "'],
'max' =>['aggr(id,max(amount))','{"columns":["id","max(amount)"],"columns_operator":"aggr","group_by":["id"]}'],
'mean' =>['aggr(id,mean(amount))','{"columns":["id","avg(amount)"],"columns_operator":"aggr","group_by":["id"]}'],
'min' =>['aggr(id,min(amount))','{"columns":["id","min(amount)"],"columns_operator":"aggr","group_by":["id"]}'],
'ne' =>['ne(age,15)','" age <> 15 "'],
'nin' =>['nin(age,(15,25,35))','" age not in (15, 25, 35) "'],
'not' =>['is(name,not(null()))','" name is not null "'],
'null' =>['is(name,not(null()))','" name is not null "'],
'only' =>['only(id,name,age,email)','{"columns":["id","name","age","email"],"columns_operator":"only","group_by":[]}'],
'or' =>['or(eq(age,18),eq(gender,1))','"( age = 18 )or( gender = 1 )"'],
'out' =>['out(id,(1,3,5))','" id not in (1, 3, 5) "'],
'search' =>['search("jh%")','{"search":"jh%"}'],
'select' =>['select(id,name,age)','{"columns":["id","name","age"],"columns_operator":"select","group_by":[]}'],
'sort' =>['sort(+age,-name)','{"sort":[["age","ASC"],["name","DESC"]]}'],
'sum' =>['aggr(id,sum(amount))','{"columns":["id","sum(amount)"],"columns_operator":"aggr","group_by":["id"]}'],
'true' => ['eq(deleted,true())','" deleted = 1 "'],
'values' =>['values(name)','{"columns":["name"],"columns_operator":"values","group_by":[]}'],
];
/** @test */
public function testKeys(){
foreach($this->test_keys as $key => $item){
[$test_str,$compare] = $item;
$result = Parser::parse($test_str,true);
$this->assertEquals($compare,json_encode($result[0]->toArray()));
}
}
}
这里,我们使用了一个偷懒的办法,那就是,我们把编译的结果用json_encode转换为字符串,与我们相要的字符串比对,如果一致,则OK。(这样做,是让代码行数变少)
第二步,我们要测所有的完整的查询
declare(strict_types=1);
/*
* This file is part of the ByteFerry/Rql-Parser package.
*
* (c) BardoQi <67158925@qq.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace ByteFerry\Tests\Unit;
use ByteFerry\Tests\TestCase;
use ByteFerry\RqlParser\Parser;
/**
* Class SimpleQueryTest
*
* @package ByteFerry\tests\Unit
*/
final class SimpleQueryTest extends TestCase
{
public $query_keys = [
'all' =>['all(user,only(id,age))', '{"resource":"user","columns":["id","age"],"columns_operator":"only","group_by":[],"operator":"all","query_type":"Q_READ"}'],
'any' =>['any(user,cols(id,name,age))','{"resource":"user","columns":["id","name","age"],"columns_operator":"cols","group_by":[],"operator":"any","query_type":"Q_READ"}'],
'count' =>['count(user,cols(id))','{"resource":"user","columns":["id"],"columns_operator":"cols","group_by":[],"operator":"count","query_type":"Q_READ"}'],
'create' =>['create(user,data(name:jhon,age:17))','{"resource":"user","data":{"name":"jhon","age":"17"},"operator":"create","query_type":"Q_WRITE"}'],
'decrement' =>['decrement(Article,cols(read_count))','{"resource":"Article","columns":["read_count"],"columns_operator":"cols","group_by":[],"operator":"decrement","query_type":"Q_WRITE"}'],
'delete' =>['delete(User,filter(eq(id,23)))','{"resource":"User","filter":[" id = 23 "],"paramaters":{"id":"23"},"operator":"delete","query_type":"Q_WRITE"}'],
'distinct' =>['distinct(User_score,filter(gt(score,95)))','{"columns":["User_score",{"filter":[" score > 95 "],"paramaters":{"score":"95"}}],"columns_operator":"distinct","group_by":[]}'],
'exists' =>['exists(user,filter(eq(mobile,1111)))','{"resource":"user","filter":[" mobile = 1111 "],"paramaters":{"mobile":"1111"},"operator":"exists","query_type":"Q_READ"}'],
'first' =>['first(user,filter(gt(age,10)))','{"resource":"user","filter":[" age > 10 "],"paramaters":{"age":"10"},"operator":"first","query_type":"Q_READ"}'],
'increment' =>['increment(Article,cols(read_count))','{"resource":"Article","columns":["read_count"],"columns_operator":"cols","group_by":[],"operator":"increment","query_type":"Q_WRITE"}'],
'minus' =>['minus(Article,cols(read_count))','{"resource":"Article","columns":["read_count"],"columns_operator":"cols","group_by":[],"operator":"decrement","query_type":null}'],
'one' =>['one(user,filter(eq(id,3)),search(ada))','{"resource":"user","filter":[" id = 3 "],"paramaters":{"id":"3"},"search":"ada%","operator":"one","query_type":"Q_READ"}'],
'plus' =>['plus(Article,cols(read_count))','{"resource":"Article","columns":["read_count"],"columns_operator":"cols","group_by":[],"operator":"increment","query_type":null}'],
'update' =>['update(user,data(age:18),filter(eq(id,3)))','{"resource":"user","data":{"age":"18"},"filter":[" id = 3 "],"paramaters":{"id":"3"},"operator":"update","query_type":"Q_WRITE"}']
];
/** @test */
public function testQuery(){
foreach($this->query_keys as $key => $item){
[$test_str,$compare] = $item;
$result = Parser::parse($test_str);
$this->assertEquals($compare,json_encode($result[0]->toArray()));
}
}
}
第三步,我们要测QueryInterface这个接口类
declare(strict_types=1);
/*
* This file is part of the ByteFerry/Rql-Parser package.
*
* (c) BardoQi <67158925@qq.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace ByteFerry\Tests\Unit;
use ByteFerry\Tests\TestCase;
use ByteFerry\RqlParser\Parser;
use ByteFerry\RqlParser\Fragment;
/**
* Class QueryInterfaceTest
*
* @package ByteFerry\Tests\Unit
*/
final class QueryInterfaceTest extends TestCase
{
/** @test */
public function testQueryInterface(){
$rql_str= 'all(User,aggr(id,name,age,gender,address,avg(age)),filter(is(created_at, null()), search(Jhon),sort(-id,+age),having(gt(sum(amount),0)),limit(0,20)))'; //, //,
$result = Parser::parse($rql_str);
/** @var \ByteFerry\RqlParser\Query $query */
$query = $result[0];
$this->assertEquals($query->getResourceName(),'User');
$this->assertEquals($query->getColumns(),[0 => 'id',
1 => 'name',
2 => 'age',
3 => 'gender',
4 => 'address',
5 => 'avg(age)']);
$this->assertEquals($query->getColumnsOperator(),'aggr');
$this->assertEquals($query->getGroupBy(),[
0 => 'id',
1 => 'name',
2 => 'age',
3 => 'gender',
4 => 'address']);
$this->assertEquals($query->getFilter(),' created_at is null ');
$this->assertEquals($query->getParameters(),[
'created_at' => NULL,
'sum(amount)' => '0'
]);
$this->assertEquals($query->getSearch(),'Jhon%');
$this->assertEquals($query->getSort(),[
[ 0 => 'id',
1 => 'DESC'
],
[
0 => 'age',
1 => 'ASC',
]
]);
$this->assertEquals($query->getHaving(),' sum(amount) > 0 ');
$this->assertEquals($query->getLimit(),['0','20']);
$this->assertEquals($query->getOperator(),'all');
$this->assertEquals($query->getQueryType(),'Q_READ');
$this->assertEquals($query->query_type,'Q_READ');
$this->assertEquals($query->operator,'all');
$this->assertTrue(isset($query->operator));
$this->assertEquals($query->name,null);
}
/** @test */
public function testFragment(){
$rql_str= 'aggregate(id,sum(amount))'; //, //,
$result = Parser::parse($rql_str,true);
/** @var \ByteFerry\RqlParser\Fragment $query */
$query = $result[0];
$this->assertTrue($query instanceof Fragment);
$this->assertEquals($query->columns,["id","sum(amount)"]);
$this->assertEquals($query->group_by,["id"]);
$this->assertEquals($query->columns_operator,'aggregate');
$this->assertFalse(isset($query->operator));
$this->assertEquals($query->operator,null);
}
}
最后,我们还要进行异常测试。为什么呢?这是因为,出错时,你要保证抛出异常给程序员看到。同时,也只有经过异常测试,才能保证代码测试的覆盖率。
declare(strict_types=1);
/*
* This file is part of the ByteFerry/Rql-Parser package.
*
* (c) BardoQi <67158925@qq.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace ByteFerry\Tests\Unit;
use ByteFerry\RqlParser\Fragment;
use ByteFerry\Tests\TestCase;
use ByteFerry\RqlParser\Parser;
use ByteFerry\RqlParser\Exceptions\ParseException;
/**
* Class ExceptionTest
*
* @package ByteFerry\Tests\Unit
*/
final class ExceptionTest extends TestCase
{
/** @test */
public function testExceptions(){
$rql_str= 'aggregate(id,,sum(amount))'; //, //,
try{
$result = Parser::parse($rql_str,true);
}catch(\Exception $e){
$this->assertTrue($e instanceof ParseException);
}
$rql_str= 'aggregate(id,sum(amount)'; //, //,
try{
$result = Parser::parse($rql_str,true);
}catch(\Exception $e){
$this->assertTrue($e instanceof ParseException);
}
}
}
最后,我们要集成到TraviCI , 并且,用CodeCov检查代码测试覆盖率。 如何配,这里,我们不是单独运行,因为,本组件是无任何依赖项的,我们选择集成到Laravel,为什么不选择thinkPHP?原因相当简单,它那里连phpUnit都没有,最后出错,用户认为我错了,岂不坏了我的名声?
其实, 这些东西简单的,官方网站都有详细的文档,并且是免费的。 TraviCI配置tavis.yml如下:(是基于yml的配置)
language: php
php:
- 7.2
env:
- LARAVEL_VERSION=5.8.*
- LARAVEL_VERSION=6.*
- LARAVEL_VERSION=7.*
- LARAVEL_VERSION=8.*
before_script:
- travis_retry composer self-update
- travis_retry composer install --prefer-source --no-interaction
- if [ "$LARAVEL_VERSION" != "" ]; then composer require --dev "laravel/laravel:${LARAVEL_VERSION}" --no-update; fi;
- composer dump-autoload
script:
- ./vendor/bin/phpunit --coverage-clover coverage.xml
after_success:
- bash <(curl -s https://codecov.io/bash)
notifications:
email:
recipients:
- bardoqi@gmail.com
on_success: always
on_failure: always
测试做完了,集成完成,你可以将相关链接图标放到你的readme.md中。如下
(说明,上面是截图,这里无法正常显示动态的SHELD,只能上截图了)
这些链接告诉用户,你的代码是经过测试的,这其中,
第一个是 TravisCI 编译通过的标识
第二个是SytleCI(代码格式规范)通过的标志,
第三个,最重要的,就是测试代码覆盖率,总共覆盖了多少。我们可是99%
后面三个则是packagist.org版本与下载量,以及你的授权。这些主要是让人一眼可以看出是否可以用。
到此,我们的PHP代码编译器开发算是完成了。最后,别忘了,能够提供详细的文档,我们提供了中英文两个版本的readme,同时还提供了在线的RQL文档。因为,我们修改了一些RQL的关键字。此外,大家可以看出,此代码相当易用,因为,就是一个静态函数。为什么要这么做?越简单,大家用得越爽呀。另一方面,毕境是每个版本,目前不清楚客户会有什么配置项之类的。当然从简。
大家可以到
国内链接: gitee.com/bulo/rql-pa…
下载完整的原码。如果你觉得有用,别忘了star,既是自己收藏,也是一份鼓励。
那么,这个组件的效率如何?我们在内存仅8G, i7CPU的笔记本环境,测试下来,最长的RQL查询的编译一般在2毫秒左右。看起来,这里有10多个文件,但是,对一个懂得设计模式的人来说,每一个文件最多300行,那代码阅读是相当开心的。换个初学者,一样可以一个文件,3000行一个函数,也能完成,但未来就惨了。
所以,一些人认为,代码越多,程序越慢,实际是不对的。代码快与慢,关键是看数据结构与程序结构,不是代码多与少的问题。比如,ElasticSearch代码远比一般全文搜索的代码多得多了,为什么那么快?
同时,我们也可以看到,我们代码中,没有一个函数超过30行的。
那么,有人要问,你这是不是重造轮子?实际不是,因为,第一,目前任何一个PHP的RQL Parser均没有理想的程序结构。第二,更没有支持写查询的。第三,有些效率也不是很高。
rql-parser 是我们的第一步计划,我们是根据组件化的原理,下一步要写ApiBirdge,用来真正增强RestAPi的调用。所以,这个组件中的RQL与现在RQL标准的草案还是有些区别的。
自然,作者水平有限,也希望高手一些参与。RQL作为一项新技术,我相信未来肯定会被广泛运用到基础应用架构以及ORM等领域。作为个人,做开源的力量是有限的,所以,也希望感兴趣的企业共同参与。(全文完)